From 75c06cd2156af574694f9a9d9b0bc1e19180bf19 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Apr 2026 23:38:14 -0500 Subject: [PATCH 01/81] Add ParagraphDetection setting and toggle to settings UI Introduced a new ParagraphDetection user setting (default: True) to control paragraph wrapping detection. Updated App.config, Settings.settings, and Settings.Designer.cs to support this setting. Added a ToggleSwitch to GeneralSettings.xaml for user control, with event handlers in GeneralSettings.xaml.cs to persist changes. Also updated the code generation version in Settings.Designer.cs. --- Text-Grab/App.config | 3 +++ Text-Grab/Pages/GeneralSettings.xaml | 10 ++++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 17 +++++++++++++++++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++++++- Text-Grab/Properties/Settings.settings | 5 ++++- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index a1c719d6..d2fc2c4c 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -241,6 +241,9 @@ False + + True + diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 75b2bdab..80794c77 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -301,8 +301,18 @@ FontSize="16" Style="{StaticResource TextHeader}" Text="Correct Common Errors" /> + + + 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/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 073f399f..17303843 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace Text_Grab.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.4.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.5.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -958,5 +958,17 @@ public bool RegisterOpenWith { this["RegisterOpenWith"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool ParagraphDetection { + get { + return ((bool)(this["ParagraphDetection"])); + } + set { + this["ParagraphDetection"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index be4eb667..40d0113c 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -236,5 +236,8 @@ False + + True + - + \ No newline at end of file From 935ea79ea8630e969b3fcaefef1dc303b5ba086f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Apr 2026 23:38:39 -0500 Subject: [PATCH 02/81] Add paragraph detection OCR tests and helpers - Add paragraph-test-image.png and include in test output - Assert "paragraphDetection" in DiagnosticsTests bug report - Add ParagraphWrapDetection and IsWrappedParagraph_ReturnsExpected tests - Add BuildTextFromOcrLines_UsesParagraphDetectionForWinAi test - Introduce FakeOcrLinesWords and FakeOcrLine for test mocking --- Tests/DiagnosticsTests.cs | 1 + Tests/Images/paragraph-test-image.png | Bin 0 -> 142220 bytes Tests/OcrTests.cs | 91 ++++++++++++++++++++++++++ Tests/Tests.csproj | 3 + 4 files changed, 95 insertions(+) create mode 100644 Tests/Images/paragraph-test-image.png 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/Images/paragraph-test-image.png b/Tests/Images/paragraph-test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..d8e151af2889035b61bc8a14d6487e08d6cb03cc GIT binary patch literal 142220 zcmeFZ`8(8a_&z)$R7mesvLr2*vJ|p6l2rEWSq3f0GWNkRgHn2@P}XEGOUTGJG8iO@ ziNS=J7-F(alVvP3#`4_L`+2^fzuA4{R3+`(ytNy= z-xF}%As7M?YUljp>hynm7XsNSGPc36M#IkTc_kY{ZAiF~T+lo1GDMskO&2zi>`?&twyb9sc{%@<5|M(@o|29Rr zc)*?heVvSfNdC9w#`C`i{cj}xcTE3xN^xew|E{S2p9U1?hM5`9SEe#6v|(jjLqh?Z z9ob#|jL7SzKxABPctxC%v*$cN&$V*7_y;@uu2WlEImxFAwxTQ?(k;{7Q)jfONb@UK zF7y9$`FU#cL*Z$hl2)QY^v*W5{DIVM?HsG*Q=6VAPQ<_d_t>#xOyZ&$8iiWSOWfXM zwMToWxgRwvv35VFR8+VcVU{?x7`=-#SGbN}+PO`pr>kcP8d`=3e1aRvcOhVoN*Sk$ zrgsD=-xv&47M>Ul{pa$HSH-6uPVDedZjigg-5(|V_d{+fch5<z*wh zWmSTj;p9(<0O#Dl!MR&nG;rO{LeGyp-Tmm+b=vBypPI#*iC)bv*1|l~$lN8TREBQJ zT!{9)=R#A9Oy*lfzFVR~5tkuRys(A?m-`}`f9Y}ZVe2K~{5*$r_pA09u;C)jM0qpo z_^Fq6<%f{D*J?(dIS-#6MtG@b^-oQm1gB{nOB_(U5D^@iJM&qVeFd?bbLM+FW83Prm|3UV*wRwcI3a;Zq13h$d}n$}EQqmQ${m|>);EZ)KBr>wGWW}AM;OO9m7X{xVBO|BuBrodfF6Qx;M8;}B z8#tf{0yzie?;~Cb6K9=ggJl0qHUjMXKI&tb~B zDGr&37Zw(3$Nc8DwzfKpJ8obqP`2cP_4Tk?18_VyfjkGD6fE&Y|53df+q4-ZO?kZ}Kh9!O?VmX*1ZPz?HWu7nMn`U-RHMl!_mxChS$H;%fhFEqyI>sl z--Mvk74xX`oSX2d(JH=X?3?7066(74hW?S)=kFFUScX`s)rg5XAmx&DKyN<~KIdGVV!U?R# z;%aj#AwK+Q&7Su60TLxu$}%Z4$O^<>lqE^b$r1gneLNK}R8iie?@$a(6MM4Lt3}#zy__n@4B!Μn z$fNI|(@68&85ry$xSbe=eoT}MZe|}j*D0=8mpJ$5PvV2)6FX{ekMld`u{TSXbxT+% zbNm7$TI|^Z+dkzFkw~n)snC1)N`}47WByHy5whB2He^-Tzwe;DjXbvj10B7H24O0O z_7JS7ztKzEIGM-ZQ~-=lxzlw*#?=N)WOj(GN!e?;*BKc{wi!u?=xBe0ijlEHmZ_no z+Q#uM7Heew$|K>|_KtTQuCA}I$MTim89J1`u4uo)s=hFWgJ(Ai{Gd>FKBTCEo5*jK zGJZT6(mXpW=E@|keUu$Wu-?f!DI89qzm*wAJUu5+r~KHMRLkG~9ctqP`d{(fMcwnKNg8#ZZ_BwPbk??RDE;>jyowBd9^m zc;Dc@Z@AjD2B>nVcn}QUNkh8Oo*qU}=L0lFXseD+YvrWdRTJg!5A*2XX)fz36qp?? zsFtL*WRNbbapHvAOs%Evy-b1M2>)ftH<8>75`291opHnEH{YS73{d&K-fi9k4) zP>!f(Fz$Gfa*Eqs^(==pvYJ^P+tFN|*7Q)#Oq~4%++qdcmi^IFuWvWIFgj{SyPfm$ zBfcQ&>W*Ii_p^~H_`3VjtKte;r8%6dW>1RBgk~b*=)Ha4$OE~5>y=IZt!qnepQaD$ zE;E8nN(4?Q8(zpSROOk4lS};;Qdb6G(uXf+*Z#SDaH4O|{hD~CS^7yc($&6CC4=4_ zM^uLny%d5anh)9vWe`rge!4kqBu94pQ1NvHQz9A;gL&OFN0U^{NWGzJsjXO_u@dJ3 zjf-tJ%u6IV;ntZ?H#W)xq^pO*n=`ML6??Hizw-0a0&yzm79+uQ!!d6>F$#mX3d*T*%*0qTI`16OWvM9GawaZJs=o@c0So0v}?=+Y4rZD-IwSu;LR=gllQYp9Xn2P{R6E zu?SDwdAg}Z=*yQq9-Ad6$82jBjE_7E&kYL_wykqkVp&|nwK747_G*q$D*E=Pr2{Njns5l5)mauG zE}QXON$^Nx8+D2DM*l49Cds69CG8!0JcRA76cSc83qvePwDF_I=Us;Qpy;BlD2ii7 zVd~1_Vrw*XWNPZpNx`^#XFr>V9WjxD%c9?2i@}s1G{}2@BKF_~aEe7$05Esu&RD)G zjuph0w{4)H^~e6*zWd?!sva)mMb63NMMQGMMWVI2Yo;=;R&yBsg?dM_%n@gE)o@l6Uu=ObshSC@y@rFqeoBGNbNW7IbCu;q`ejOn&r z3vU$_X9c*Z78=uZdSS!KI0YK+Pt}8h$_@Jl47*6S1YFz%P5P45{pAX(`+NLN1@mJc zu3CmPtxA4dV>hddvgOyv^tgR#9!Iy;rL22F^B8$8MJkYJ zG`gsyL>45skg%|8DP0evwl)l{j9>M5i16GG72ZyneJ~L0swk1Qv&~N033=E$H*>F1 za{C|miS-4_rYyK*roM2#>xAHDXeFPaTW3hc?`@# z&Z_Me+$etp&a<;p!NL66DiuBfTce{-O`KPEybv7fW*!_*zB4efZ6l&t9Y-HnN7HN4 z?qtkpH8(Z&g=iOLWr1Lwaro(OL(6a4`Eh}g8tF?rR{?m{ zOspdsc{qgH`iDj%kp+#7IslmJ1Yl1;JWgah`=bjMxhFV2B!G**ujQz`yXSNKh%eLP z4JYbV-OQGFxlj5_VD?n-T%d#$*=7;bO8Qz#6~@G3pw6!aoNtSR-Ossz-KC{@ zD(udJ7zd)C6c6;`MN1LG%^ADP{6VmF(T9(3g>Tx7O$OwT`CYpu%2~G!m~zR7J8HPv zzKbbal5+!>*W<#NN0pW|`MW_%Y9+=&wz2?-iDa*ZHd6Hxu8J6n@Nn%kwEeBZzynIm zD>xe)KxBLglyH{T$lqtY|1tN@oD->;-|x3uD?p=TAL?Y>8)ag43Jx z`*$@$`2fW12s90pNLgTyl34{#PUkLPS=rw1joujY^YdeZfVI%kzCI8-aE4G_Q=cQp z86`dOT`T^kVHXzvsrO#18uP1TuPrWKY8*3<41;{{-#x3Rc=JPZGgVJ=uKP4f8J7|@ zgE`1F29SeuFOpXc*LUg27Yzo_xm`VynyVvvR9xPjHfYUbuJ>5!QZxsk3;xeUe=iq5 zJ6!47?WK~rI6^dDuP(qIpDv%<^%0aK7P9TO{eSXTe$=ldnmGl4%J4yn)K5xFG`PFq z+*7(UI8OS|K7L8+<)5QNCb}&*i}uNc)L%#%)bjHg&JkuBg)E#6N295FXde(+)(5Rc z`NoXOgLB`qybyxmMqdf%?>7x=JO^Xa=Wr8@zjHE00P(Yc9>1n#F86z#-L}Ra{aZ~W zh9pU5|D;kM6_ZMXHoA$82-wAo*D^__&>_|I(5FqD!h>hl zAjQf%)+mMeSa6a`H5C7i9s5ikzM*eIwk;11MbG>|02f?}9mruE{K-0%JF}DrPBFoEIVOr1ci&EH z77FT$FY9P9Go2O#;|q4JU{k~m^o~6TiQ(dc)^wnNA%8*hZRW~~+HBM{%Wo08<|L@3 zbS2Gw5AqQd_Lu5+t2|${%!}_I9eX0Sp}=P_mb?xA_Fg9731VnqVhM!QfBEOY7=@At zKbQWOO@N-Js6WE-O4(JU&wKXPT$!9+at$6vJlNLgE-5uQ19YJ*zevm;J!rlcxF8TXbN?M z2`xSr0wMV#z_0K{5MIr3CrX?`|2;c^^JF6Qa}ro6X!vL_yiGEJX?O!>-~UEV?9~2W z45ZJ|G^+tHU06id+YQdkd5Nax4>QA~qWqd8%q7_nP#k<4PXMF}$nF4C54jg880c#H z>lksKoE|r*Cik@8(CGsV&7VJiW?8I;3pdZ(y1Zy=Y@|64d)NKcbLiilt1p}_!R+;@ z8458;o-Tw`>7G~l^yHxu?x3KhNP_zHXcX$(uPU(iAWpKxV89m)^A_&kzkihO3UJHl zK{$JB{B3U?G9qGtHyT&Y4A3G_ts-wHLunm=nE>%GB073z2L-MXUhf3L)G~FG!Cw7TI zybwtM&spQ1Fd1fOHz1UtJn>kj%OYo?+gS%FZs7q6EfEt@L%pnPaEAvm{4z5X|m>#UB5^cR>s|o z>}6em#sQv$niwF%^oN%~dDG;+v$nSO(hk(S(K}n${#M=oTWWT!T^Q_HyAnG=a>#4q zoeV3hSH-BqMJ&{K#I;s=_5vHM*9L+7A~@bHPokbhFnzAH20Z?hll zUilM?w?@{@OW5a|$k@A*Wk}^!Ge34Xh007rF(?Ey^v=vEB@LD?umlh@;a44I;;k;YBUdwG%mmhoD@p^Tyk1K|@h7)sjvAqCh zM(-<+^1=Dv#C2o&$fAP>`Z>u6_M-?hwA$c^@&Y?B62=W~DrJn$3{3i@*>635cjOCD zksxnD{E@~lkRqC)kvDDvW=W#6nfsFxn)_Z>E5n)VwrJcDc5229vU!)zAicx2Z5_S0 zeNlmmRd{n}f3L}3VCZSI?{7}!@wWfCjy5K(6owWyQI8=x*us{-PZ^9jfRCR0!e@i? zTbz}=>dAG`PR&%PtfXmWeny;ta8WAAljdSJ>;1CtYs^Z50f_ync}#8W@Y4>%?%BTH zz}IKx^5jk#b(2cM& z(ma3_1So7k?KEXwhcDisG&kfNvduoPwdR$Jy1rkjuGfp=(tc>J!O`!TzgP_}R|f4b zh|aNDmCHIbuU7-!b-0^HFFs!0>rJ_KIy`^KQ}UdURri$9yY|UTUayWZE(o+w?UZ;K zKW@K2M60vp01SxU!SSEaUDTQpK%xTpNr%(3H&Hc_tm1Vt*(pfEQ?1QB-MyBwz@ep> zzxzb{ncF~G?h-EE7f2j+eN4jI(&cc6>Q-Gn(& z#w|JbEk#D%W{|!S3P0&$Yy=n|aoe0nfo)DdKt_#H|BJ0o-sdQ66Q1pgf53H}v}|TP zXonpEn-nRIbQkktBI&oyB#c9d{`U#Q0C)C>K_B||(*FUP9)C;SkT3s*(7CxMJ0v&2 z@$t?MFD_pPJ8BC4RRI$K3&G)W0nu$K(hnHtJa=K->MN8dxBv*|@97|q06f*s8Ms~b zc5L(y8?XNI&(!K_+H1L=dQ!+-K+u9K^5@TU0Q1}pP$pe>U!#Aa-V$@0@py0U0Qu7$ zfzL8Nft%Zee0Tt+RV%-At-dOnca}7lTCFg(bN3W67$E8#GBP~;QULgaMT3dGK!A+7 zjC|VohvW%(RWJ$rK{BZ$#?K(l0XGOh+G(TL&6h3m?1>~bDu>8z)p*1ivwXvJ_aXZ( z$xdnMEA+Ckbob6K5kEK|0N%*I<8g0g&w#}v4l6q0`B9alXo^`GgV_ma9j0E+BN~ps zm-86p{yqlDhr+dv6>cZKah3x#H1DvMSO2lwwV*>==#pvo^I3>U~f&kKo zcjGJ@JV>A5zl)%v2Eh*$AnpzxE1$#UPlG9)IMvq@y%SK!p=A^QUXj4{RcxsAm8l-i zqlQTQk2KqA@{{ukc>S*+3#J3-_5Y+8X?BvvNY_?9`A%P}PeD(AQZf&eS1&GW?QbwTURWE}jG53SyQB%-$ z_i{_r)@x8H6`q8&rhz327>}_ZKc3GZr`g+qpqvEdW#^Imhus~w!4YIq_HdK}2S}8f zvwlmA0%7*=>KhRHQj`j0C;*{2GSrTMDUf>dh(0Y~ngvP>N5vL{%gUXR+zEwcd_4EA z$NIAN{*we1pn=s<3OPahI=>}*tKzfvHr5wh;m2ZvUzwNGMQIuRv%#3L z0PjI)084dQcV;JK8tLMrw{cJxRMdd=-mdT=mXPEDC(TxjKLHlN+pV=U-$XvQdmkP| zT?JzX&@xELfUXkHCe1*g@h5^{N9)EHf1AT}x0_D*9q@>%Uxid(Ngu&Y=wsCPMcYqL3 z81A-ENC3F))^FYE`T_u9f%KkhK@Vyn;HERmqIR9_CRJ+wh>yiz@ zmCs?ch@2 z(sy&`)<+`eKB$@PlfI&VC7}y3cDw4P2*_@LE#Ehc5ND)=wEQdU-sz%uidpVQ^W1Gv zluCN#3=)-Y_qe?vunFKpBJW*}*-R>^{3V6>{vSyzEIXY5;;|_>UhY1n$s%X(%lyWqRB(@gaQIuPqSQ5Cq$dB= zgcb+CD%RV!!{b96CA-gpX)5O*i~-tYT}#{e?W%k=GkN}9fF15MZS1Sai%oN&)!II&0tZirDy?XNCuc}+i32xg)=Z6tK zvU>nYn7y>M#janaUjf=v>M0}mn3Zv|)c~Ddv9tl!GoWu4m|?;^bsA$t4^?W`0(m^9 zC#eOF2aAmaRyci~J({x@#Bw?vep0aIazpf>Zh*whOFs2*2l@u4!AzT>YnP^Bi`2g6 zNcQc#+hTFylltE{;l%#^RQTsjA!eZ9lZ;qvviP>5^I%v2xqC}1vCecj$SK2k*y;kh zMxlJrQod*zj>oOQNp^7(odqCmERQ5CRu_1m1b0@Ea1oIOAMO4M(xSN6g-la#hPy-Y zkFbMqx>=q&?pqqB3{P{OE7Qh9$@AJW;s%(fBJh=l`2vj4=(Exfm=!sin-*5&a$iux z@8;$*AahuXNbcCx&798d#m?EglQ1Dg7HM5ewRIQN(YzM>ph_+Zhe47|jLryQ3sE4^ zJ`yWO;%?DTx zi&uvc%4@vDvi8XvOgyC_+N%2m|AU_anv1q_BTXI6H&Sb`n{p!l$194Rl20Y*PNjG` z44fb7^;Iv2KhEe0Zqiy!t^fE+;vo7L)vtc)>HY^9+$l!(Smu%q>7+n=FCCO0K@LYv z#9Jw&e%d3c)+s8d9?shGu_DUhSM0@QiWCk%eYC1xZb~-Jb!mDwH;PriC90m7XJOUt zGS@w9siNf~Pd)A)Iy*JR{>u0yve5bOmwRmOCaH5dA#~?7BgsPIHsERT;U)9hWmLoZ zF{rysM{)EN((_f+Y0@&0aJX_o8#mQofWh2QzIO5Yq6J28G~lxG&582E2Yt4;E$A=cR~IH8svpyYYfINYH$76<>e)TnHvIgmk)DXD1CwV-T ztX{f)8t>b`JZCaCt!Z8vLJvk={g_f3JZNZn04PmTW-_Erg$Myhplw+edX36u;evh~Q4WK#LNq1BDCYH< zanLyET=`k2rtPbi-{ka)Q{YbIFrAQM^$BOL2ug&d`O}UEl~zw?+|%8gcl{$@WP7RF zrBa-BF0(tRi*$7uYs*n5j^DR{vFyr}tcwLHo+({xiTL1Sd1ddioV})*+laannQ%Qf z?YYqgYg)cpIwG1eij8EtqrU-s-@5^|3i>{O2y7Vs+nj9gFG222XqC(?r8J zHqyv_qXSy}4?e4ZQOU6-?30eC208o=ZJb*Bsp;oS49xy)#4`q|QO>o;ahVpp%!4w# zn_Y$nr~#U_>9#4_x8jFQ@E2_(-<*Biv~5Q)cd+|OR4*?#-z)u$uvhsF8tZATZU!Ey zBW@}55`X`7a*!*zQ1SkKa7DsA2@#XX+@PowTq^_6P2bjFw)<_VSpw!KG6vM(JYI$O z&XTd6WUl#pWMf>&g%Lk3#Nf{50|B-q+l(ZX)7_UzhMh|zz7|JP7ng72{EK=#tesr! z{AQqrNK@?U`-&VNoq`j3QYKSgrDsw;VRq3L;WieF>2PP!I53b(Lo~#Jo4R7dk21Jn zEUaX#2=}uD8?W;A42`3N+SMaJ8JA=BDhHz)b7wAeJL$T$2u&3#=eQ{*PF?L#ID6BH zD)EoMs41^_kxgK+t?|g{QmRc(gV7x)Io|GK?wWrVrW?f+2S)-^$UEr-_d8l-u19-p z9n-mYeGg4U<%Lk=XS}q!63Zcv)14rQ*rsIfEKu49PgqL+UY?U+5p-O} zAXjarfT);PaWyRthK$^s6;$w%=8wp0T>djTSx}X2luHk7{2U}hwyxtUw$ETsuqd*S zNovBs@qm!?!2OvjgYM+z+j6=V zAzN8=(ZUMUYH8S@(PJqKtyM|YSJSQqqQsXgK8stkCbBE9%U)8|vU=j>;(Lc~EXRGh z``+?rn~(h^+am4rw}PHENmtYFvhv@TSN)p16JdwD5mJk5yXM+t(3gXt+93PVk7098 zT4=x;ej<$zyU*o+811X`M?nk^OWrE@G909Rt>g>CUL$6b`zC4T?Ec3Fa5q2eI1uLs z()Jy4iSAmqbH+DyO=K=G+S|GFiVpdo^oq%_s+**aPJb^8mn_ppYmC zHc6{U_N9e$zoiwjTH8I`Y_m1<&lSvng)?Hp+D*0wG9!FMisy2Hw-}LRpw!u_qdAYdy9pS<6 z<+Mbh#Ei_KfyqvT^gB?O;jVnNya-~}87=UPyt72aw4FmyN`T%^(Kk(g_~7{QVZn6DcPdOx+Ob>k9ePAcl>yU*8W!)z_?7(HYmh%xkK*Z|o?7Zrjm_x(a zAqEg*SC3XkC8bZ%%NN)Q)JBC2!YE~MVLB0T3w06FMYWhC?yfiLMtr@aX`=)-zY+oQ7|*~ei= zCy*YF8Q~YPW+^6L7)^n?w?v1w)eDSI!j1*qF%@^p>M0KWYGq8D61%MnQt=z;+}jP+ zESwNWB2oohACSc3Zh02jdt#C5MP9 zSz%wkZ!07BZPxvPGs!`zSN*!kWi{UdDD;3Wza=d;L|SzniUrjVIAIWMj?%xqJfc@bHhllSth2j&vel$E=+@fWnuPXUOjLI#X!2V|&A^9s>Z>J^a?5DU)Kphzt zRwC)&|=NBO~(^O`Mwo!wg9VG*=gO<2+uz2j;gzxCVcs?m$9s4?wg`qWE7TO|ug z>s5JAiuBf`&+$tw$63h6?-9+#fPV~>03i8Z#R`y2 zM2{!Veg9plh?mN^caUWy6y>DZxu7=HM!UIKI}L9TbipH8tUuw7Yl<1@jI%j4f4BmG z4*Q&j<1k}p)_^*zAo{TI{)o>Za-cY((av(@c2I}|K10Hv1Xe3p&~ou|M#-OJ0(`?C zt7GHVT6VU}x6t0#maP|2wu4gEB?^&Bw?xmZpEddicqykfg}}bY-85Qbd*tF1W%Ye! z$B!|BnBbPadenSA|I+-2=x79fzq4JS3c-bcOXh0My+X4}=c@#mC(M zAw*4M1IZj*_Jia3fEKBu=W$cQZ2P!}7a9?*R>tvDHzU*DO7x|3vG1xBWMpJWUHCT^ z7yRp@UMBNUoGuhx(7H55P!CE9IePiRCD|VMAKie32CtWlRuu*<(-Fd zKT{yw1D}Uz!<$U^Oz6y(aRgzQ5QPybKZ4m>S@{RZ`Wg$(f|87U)rc_x zbOy!MGV1k<5`B4s3Q)nWJs*FKLe2=2zVF%{wOXUwruykqQfijsn!S#}@F%blvP&qyM}p0-c6Myq^YaGYFa>r8dNI(w`G!UQ~MlIZ>j zJ%g#vryUfRq-Wk_?iRYY{j`3WS_c9&tZr;?$@!+> zE8sfqx5wjxf66v6!yi)CdONS(X=kjYuE%b!tYr;fBxw_J2FxMOV!KujOyy9uO2-w% zJI}N5(fwPRN5)%|DhhtFGiF$ZeAkBKOUUwJna?UrzXwP>2G1gG;r$mq*EJ?-NkiH- zI4W$itu0K)E(+DOpyHmT)YQ!KgRAL5$tzmBqjR%c*5g{nawv!O)D4PHFARsXcED2e zQ^a$-B8-pyJ^g(kEN-mQ?BQZK2GleEt2k!F*JEtYGnRt#&i3hr&CXVL`;q{kAdTG0 zP4(;6P8}sRm^6^Ksz1@zEk2QJ&SSX@CRvmc^Jhni1U$S5-aIik8rFEHBWWR-dX?WY z@|`k~F=fk8Ab2CGE^V;0n-3@C-5)6v7;}wNodi<5h`CMs1^pPPu&rl*{Q>zU)z4*c zfk*poSdcFd>25R0OjW-X)@5BMpdO&0dgdj+0uNDIptSLY^}4O8`aMw^Whi>^S74&d zyZVNx8n(Gc#M_Iu9CWcO1a zR_-shNgc2R{7nn|uEP+2A2L?&R1{(yYn}M|ZU2*Jf)*{u`QNRxSgg#~a@yvAi~U)% z^4WjHB>7VWgt@$IMb;xvHD29J|8vSnj9WVgEc1TE4-l?_JOYGpkZ6bQ^SoVBZ(T>z zuu6pLsrs3`+}sF?pBKSHPqNc1``P=_gGC%+<-bOQ2sB#KEYb?v2wPoQv0*owf9GS9OV@U>KEK2T* zg)f#fHm76EkD9Kuxh8s$GRc6gy(a+WkkgD=T|K>FF=Ki6sb@R#OvR|XBYR5C=NFGG zpstnmMpoSvP3r<9@ELMXpjzQ1Wj%@8hV1qrPvUp*HB+f#dXGKOZQj-)0HweeO5HR{ zz5$9oZ5qI%*VR-D3bS=`g?gasp2Fs5uYCZB z)Tgg%#h(Dww`}tz-+PCx$ z2B8|Xg^a2frgi}F9NE$c+ydk{OyZ)NMkxW1GF5^n=AAK>c)Hn>nHr^wql<`;w6xWT zvFmvwzC#TZxEI+o&umF$%+^9HSN)0&kAADC&pjWD5U)@5gK7Jn!8OqlffAh?Oi)Nm zI3Wh=;2oY?69g0agl>Jn%IMOy&GFG#Uvk9Yk|i;Oen$07;~1ujK3@xJj1!)NCC+wB zQI7=JhsTrtu>@K)(^n9cY4Vf)24ns`o$&g!;*+#58Isyc7KNXd!U1gNd{;&V4RqESJ3Kl!YFx4#E^4X$BbZ!$Nt5( zfE1<{rFQtLp9Q{iK)BNeSUQD;O#28!%qz-k;h!8 z-xBi|IR)@Vd1}=QV)bv$-THmsuWIcyyj^|{)hPgM05A+qZI><=WQTB{B=^3UdEqBa zAiU^udpPCgmt2X%4PPko(*6<^RyFs|mU<~F_xjtRPQXHf-9Eb&W}EUN&~@}Xbf zCyAb8O>m~RX}9t4rAo(68}8qs3Ct_--7>&;_%XJCZV@~^dC zCTCMr&euYz)n)XD*ATw8#nh-4Wian^S_Cd3m_ZqLNiD1L8HNOA&t0%C3~yyZ)jLVW z3)(iTT^A&dw6EtY=kyW$>aEq^qk+>qL5y04?XAcjJrnA5?Y4?y24P(A#eDqXywQOe zsq(q}_x>j>E>`aS4k-Kt`l#tG(Pi5qz)Lha&+=C3zMVZdyVf&|jk%(c=5^r{7^aGo z@cV^xpziP)JGz&67Ft)Q@#yvlX642t?VRG-l4APlQLYsA23ogqo_3pTO5>xAUk0WA z#|yQM?y980k5T+#WVZ-)!B%w^n*&A{lbT3&MsQbH5l z2TKus#TAnTmt?b@p?c@T7e8McY&^wknfoqbk%gyex;qO{xO4UOxH|*o5~n_^&Zz@k z)>%m&Y%}CwAJ2V*29K^~zZHJ)QxhA7_i1p>T{-+4tetTDzce`2T>O&(Ay8uT8_48_m@7b=bb)nmB}H5Hmi{Y{Gc4*s@zG^;Yy zcocZD0o2WL|7{}!SBbuwmSVN`C+V*|Z0=1V_Aqp5O(6Y$JU6Z9q*Wx4id6Z9?F`Iz z>D0KAIl1sue(6Z5%Tw!Rc52WX3{)+^!&B^ukU+!dJXMsAk#zLk!Z|%M#zufMjc~J3 zm-BkH_GjX+NCzz4?r&1v9cbeB;k&YzuUw&Qy_@$AXyh$GhrnyP5wQ5zv#$lWfWiWr zpBkJ){c0TjnEOakuRCt*d%Xhi2x#SshxY+M3zVS0B9FC1=V)t?f^T;z6BQgF(FuTj zYV41KJ<<{N8m<+uux03(?pj#o@DU@Y+p&>@&{wqy?$IJ~1(xg^a0{kML)M9C8Q!e2Q^q_fI$ zJlfDH*sOcS^snj!#v*s-#b4QTiGrfEjINwzmtaM2zmsjbB?=5lW6NBgcKG}2pc!vj zx3W3S(%1+TlhVTo9i2Al8hb17`EN}jBl!gJcc+X%Nwf8#N%%owYDi?H&$q4>%VN;q z)|t2=F=y4mC<*CBRoVVBMC?2i_Uw6@Hw{c>q19dMTfM5^GluNU2|>W{7B~jY@QyrL zB+&uQ92l&slDNk5*n!fK^2A_Z@&JOR7=5gc@A9|gBzGOJEIajT1)U*c+4e?!w(S*2jE_x}EFKVR!rARz%pAoJ zCS0Ed!0xDhSv_%}ajajp!8zSMVWt7}8(Oo0o27RFSQppA$f_R}Ye373g@pyDziy}N z`l^uDN2732Enray7%a;P1tYZcs+-Z&Kbb)^=Z3UWfpq=NJl$BnWw2(Zj|dvHfK0vj zK@8v+Kw-D!Pz#(fCogFrm(H}>>w{iTdw2_Q@$huE)F`}}3mN^e_f0ir0Ti(67~K9P zcVG>NkE)0Qr71H zEZZ>!t!fH~JIg08^8*uLwZfYo57LKaAZoeHK>@A!Zx(0%AYe?;@=HwVvogVy#5r+fPb;F@_9>(7KcV0@molr|e1 zYkgmzpN#C=J*N(m{gk<2OjR2g_C-Y|-r|<%npoc+>3a>%ATV%L#{dtiMviTFLNN#7QO-1aZyT|kic*Ok5_B`LBj*U@p8uhRwWQRfqmZOX~(YnT9DR9pp6IM72u!) z9i+gi{MR0I4MSTV;qvZYPB~>ndva_;m-XjwJ@eYR&6YE)lu^*Uq)SZ7`M{abr;4uJ z0M3$tj%8iFH3pUOi@HbZ$0M%cz5mmwKXLT`-3Dx?t%zY;G!HP+7=hL$z*w9(0R&SH z&AJ$I6wF?Ak?03KNK=c1*Tmvp*b#N@W8<;g6@N4>!-V`<(SWL zKFc)aSQ&`B(!nABc3J_e^a?N`KnZVw#Cj+5!(U?((UW!lp>XO9>%21X{`Bi{L0WCw z4y~NE7aB6*ROBDjiGAH|zJnkJu13Jvf{-|kOj9{R^{9WKL^R9xW97Z4anEP8KzEoV z=^H~Ah}Oi=Ku{f~6M;P^PcPb=apUw@Ib*Lh;4}WY{O#A4{9hMSkib{aTMF#vQ1Q(G zz%#9dy(+G2;ez1zV)CQ89nuGjgRNmzCdm(Xcp%(9t8kwT$}@8kkUesl9+6x#@74UUe=2WqOlVx}}vNNdsL-i#&eT&&fx^z#V)ui*JK- z-l}|y4d65>U(<+dhny4J?F?AKeJ8Zdt&E|Bi2*gm!qiUqUg`MNh#pV2Wf}6)6~Kg| z(#sD40rfLbj+*n@3IQ*ySj?*Bnj>prrDPT>(NET5{?#bKRq+O?-?C;1_-@SHm5S28B9bydGxs{qzEtwifAK0M#HS!7cWu!VgV@(*mu zr-zc6*^j3*K^k&u6eRLS@2i^c=|bj8)x*Ze0SD6=C>DFf8W<1iT5SD~sJ{R+qosuu z)#Cq~OsCT)>WTa7E=FzY?f{#@<{F?Pr&dWbGbteMRy19gH!>d4p_qb@zO%JXSy}+d zmwmS%%>3j0K$_o3y$@EVaq{%f&U*%F`xB=?tGb`RzYhaDcSgC)VzV~wNUk27ktwRO zWj1<3GRisRXVn}h;CHVG`}<4baxS2s>c|gZ&W@E)EiY8g-|S8iEp{J&h`DH2bhPps ze6Eed>J3{+dLiTIx7{7=B7a;mF4fh_H{MYy+nggM<&gW>4rY|CDA!1ZB8!dC<_?%W zl>NIUQK`ZP(Z=I{Bs>#z4!gLzCXs>n07MUSyIs$?fSEwxtQqO!`qpLs{_s0;(cE4l zhNvZToJPknp~t;&6Zw6V^{0TSrqORR;%=pl`Pql(mW*kfI0F>#7pL~P{%}k_1-yGX zw_ zIoiFCn1^MBfHF~{)62I;d!)Dpqc{)T+G13FW3MK-6NWZ~bx(c_@5VkVk;?NOsJ>Bq z9;ZIEzBSqem<@--a40Bw*c#g&kaXeZ3NX5<~#7c^6*r!0kB$O%Dq zNRMd^9`>$m4y z=I&-G!NMW;v=O^!OC(M@2$^nZ7md$%@5>v$>wQ*PH+uBfRA^()>XXOw-_o|>@E5&B zv5d=0TX&^-@`|j7kJtOCbgAN_VB!Qqg;FKw!I9bFripLj7fR-AT$V7~9#(i`;Gate zq8Th4_sSgCbcR>X2Xu;HO$@XVdX4jF%F1AZ7qbbp>yDmGG2$;63o{@5Gh$jS`;r-c zHgT#~ccAD9#90W)G&ckpH zBSWz;+i9*AQGq|sZug@yj5c7%83B|(Dm(4NGn0*W9aJ)LYZ}W*<-or~E@BC&ay+tG6hAJ$HaY(<92t_T`=Y4wJp>4(0Vywsd(+8|26eC z?piDL*G8E z=nebWnJP`}uFaCU zGk<{@$tJK4uq^k9L&k^RB1(HHX~!fe!x;1WPkaPe4$2k1>*E>d_4f_NWoL9`xb73p7W54EnEiB#}b(kUQpa=!u41AtbbYf+}vq%@&mHXQ56&3y0k~IKG}v$bKUM zX?_3uYFvKN{0%cc>C@1@`FR^#L8yZO7D$)FIb{%&2pIX7F!Z&bNSJ_#WyQ_NO()%A=8|Ase1bMGL$Hj6rXS~{~ES$Jr0$JSd17R^k1x>ZEiZV8;R?2tZle@)+ogm)qzssZ31{j=G4*Q^o(=vJ;!%3eaZ~JW46s_yK-;B-go~xvY-Z9H>_Csm;N< zSN!fQdvN^i7P<^{Py?3L-(Y3IC>dq6y|^;}vBd&Y&Q~Z}zc}^hbvFqB=TV9_`g0*< z@d9W*{5R}vQc_Y~iy6N(P~S)Wn=BVTiU%Czw361ZWznB-r2$DKC!L0C*5-%CR0=JZ z1KZ`v<5fpd>A*w#=vH`k_^5!P@rbDAG8g^A)cugWYNL4ET_;)GqDraQao3a3@G1Dy zJCYL_!Q=0U<(^DntmyNE1lt5XyUn=Q(HI^9TGKXYLtq4J7Qn z_FCWaxm0^~6*v<=NXqtw*5yuF7)18)^$FHRO+;i?`7XK(c3l@ zv~|hAjDf;1S843iec+MXO!U(xDbEFxKdv1+LNOk$ZwaU@Tnc4gNr`p!T+pXhtu|!^ zUk~Rks6BvoNx@Qspau$df%9hP=8)0*wI-mC(#Z_d@?MZ|;A~m!?|-8vqA*k8E#pwcj?qsc&IIRuJ`EzsB;SC*~ZVkdQXMZ8()aEX;M0LD_#oZ&N4?4}t{1W)C zHvM40jak+wH;wK*k-7vI?tQ+neT%zpb}MxD&^k|i%(~nyDy<>>QxD$;J=jnaieImr zX;j%TV}oPduo~nyptkx0i^zo^tGS3Q1FVH#<5ZV;Q5wF!O<@kXphK^lq&OoyJ9a)R zwnQoSy9I4<-q6Tke=HQHbE$IrwGT)I27Rnp+|BZzffXKKxW$(BV&iM)$!67)vk6)a zu|D6HPGIWD&ms`iU#@)*bZmwB`XP9{k8(%H<>)K1FYIYjFRh~|eTVe3nQCoY<}5e| zjyuMCtlT~gXv&;A@)ZC(&oW&0o;5!Mm>J$N7`lvm-c9;T9t@xTq7bQW@rSjQRqC#m z>NZ)8>2gLusy=%8%Lb-;M)&{}ZlUa{o?$Kna+z>Y`tc7Zm*u$QRV(fhxF!KM-Io#iIDwOZ!gi#(PNIp5R~Hn*Q-wEu zt*WLTB(DMUS?!3WP=$(gQW4gbV;GoHqbau#1&Mzu)eHB|b zyfvzybeP9p&lrHCyP%uXgDYfd=kLyQ89^yLS6k}$BnEf4eM9#-hK5Ur$H!*(3;4RZVZg(ri5*QRW2F~2k8fRT zL5%^$(?>!?HQzDlD`~dXW*ZNuHK_xK#)KS&!uF9yGkf?^K$Br*krj0N8!cIx3W4+e zb#(f3u)=}t^y%oo?}E(PszZaGevMPboYmqrfg`ZeaKn!iFP58Cr$Ae_GJuRFkwr0+ z^b4N|(`Nme_YrwKKA^zptNqpyr}tw|^HjS3()Lxv!`fa_W_pu3D=OK8Gfmp- z%Xi&(`rW4Sgg*nWmmj>CBO4Z<%7|$P=@+h%Pe-$$xelg$pN4gK&dn;Olg_UpRewiz z$*D8*CxDXyi}Ck|!;g0WZ|f&zK0P-g$whA9PT|*UK3IXUa=ViPany13ZZE8t4$9bD zghdM@xn8H=$?Z^Zc{lDWL)BrDj`ebH*quWLX|gbEQHtSaVBuf;i61q8vIjx{)Cr@NpFTNn8%q@yu-t!i5 zC~KQEJ@$6)^gZ=|sJYn?6VB*_^KfJYIojL#=Av@RKzFyzs?=Q0oVZgZj9BORU?It5 z`SI~rbJTI0ODqFOl^5taeRaWIUKmc^h;KT)f@N^Co1P|%H{Vfp8mlBK8}z%p^A`WO zR>Pix^*-8v@c#B`eMJI zekU2_NHpOTG+{p&lr#voo{msb$?C&s2(5V&VwD(1{ZW_(7^yiWYln8UgDrD>R<5XB zRJYenEK^rUr)Aj`EOUEn6B>?v_xv*9nS9jKW=f0e-WXEv;{4pP-f1<%ILBJ)v*v}^ zIywXYZ~_{FdJD6F68R=A^}|uS+ckRV%n`n)B5S7jc~y$#Eq6bXEdiF4uY54ubMxlP zLx$HRg*XGr5C46er@fjZ8hb(?8jNjtjzhVo(|&nw1{~gDp;9GngtoouzyzSSK*566 zlA(flWP-;mn|(?ABcR?w;58eXWw?u5f)ml{gBBUjDk>`W*ZRDzf8y@QsIGFCGyt|s z^bTv{(8|JsUBM=UgrBgSefumQ^>(&U1i@B6CDm$pnL+fd>}qTBE$ujAS`wy?SG_r@ z`c;YcwmHR;b&O8)E6wkZ=|3@3WG2RTnbR^mc_w||S5*l1nr5N2k#^IX3VY9P;}x@@ za|L>>UFyD!%z@H!Er~7B&_bZOYFnV`lRB^`1u7ivqQbnA zFm?qrblh2ZN_2DgKDY`muX@BoZ7#sL=?$#OofB#OCmp9Nw zL=3&a!#P-LWahDSgbgD9S5kGWJ2DmbC<=VBjxCV+KBqO+!Bday+k7NI)sm!&Phqga(ql_IU-!}Zd&G{R6gzNx)TDtT@q?AP1=mNJX-S~9t|R#lB9$VW}0 zp5*ty3;gm)YuqAd7H~Zy=;+w^tnaA7Hh^t z_aPKIzupMc6ygK$PfE%}tKrPVdE?HA=jJ8nv+3ZNrqTh~h_w>aRRbwpUd$lF6W| z(RL+OW5gMlDU-vQD-=m_i!_yjk_MnXR5G1M`M;O@!nb9PIZPHHoFYi{GuRF?IaA&}-j@J`typucp zWoKAY!>U~#`i7$0zRz;!mn5!@uv8Qsd$T|{r#jpHNL}k%eu@mQmimA%*THZ;8+WLj={b|?O)B&fjvg?oV77)3w9VZt+d$RyusuFnyWmb^5SKRJ z%`9Ik%b%r4C6DCg6cM`FF5Gvh^Y%^$cRI(H&~4UFU;pudJ@vDk$#?WfSK)@KrnbzN z#`=#AY&M`?ltKJb)V_usa8*t5N)9|qHhtI9_Lu>jII+MUwB)OyL&A}T7VX95>6dSc z-vhN~zAahtQJT+Yi@WqJk zT^;K#;p1PTYjxLU>>~1i&&SNbX}PH6PLY#MRnR_-8om#6O0zmQ2Vd5g4U1>O)z;~& zFNC0Vyl9yu#2e%$mHXlTDW;Qg#3l4kU%@RnNWtPrv4|0klZD^j3HM@5l6S%Tx&iO& zwU~c^NmEZ7QxV>j;7ellyRDyw@oUB%{3Fj3%*dDM{GmAh{bAivH?{MIhW#;X>j^Fl zS?I1%)9;GZ&tGqvFYrno1IE}_fMpbDJwb5KpBhGAeKvsBJ4hF7?#UYr#^wys83$0o zHs)P>n|4yBFjrzxLi9}i(72j%)5uM>Bu`A7 z#Y{cPa!6n&`Oa2X-?T8=-+a(1i=ksU8;hXP6!iYU@|#4<_O(abEIbg>Xnej*QC1bs zy!bGwAetzLFy>UIbxP?CZGZ6UXWbA7Bf^Q;$doLo;mPCi>#@TRt#s zt$(*;$Dis;^#Sn%3bxdDjeoufOv`&1R9=s;_kSuk_X6}$;S6A!DExoqTUAI0B5he-HEPu3K;^ z=aXEf2I#m>ogAwni!teyn1%fMvbnjrn}61>Nc_kL=n53bgEQj$H4bH;aD=TMpa;Zp z@s($Sk3Aol=gi&PdMMi}T!dm&5woB6_n8&GFt&oL>+VtTHjp zvYNC6Yky_Aa7i0#-h1|+7W4p=B_U(Swx#8z${BUOkH0`+uT&IYr#H&Hs<9fK&IY3I zve~(yZvU={RF@9Un9zaOpyTOD8$yL&WB0njE9laWmSt*^D}|5m{V>vBs-^m^;G#wBz4g;B<!vqu>#mDx@;z)SbWxz4DzM zr!i>N{qmRE3$V0ETh@GR{SVeAMlTD#*x`{|FINlJWxKMP2NFjo);->O_ukgp^LcOq z0JH1zIUzm3KQRWZoURQAOWg;F5#HS%<#mHUAdw4E#6{QFbcTTsW(^+!HSd-acZ6?U zD0e9X_S*JlQ!lsbUy`UjN5>`Ce+0xv4}^jrn&SCTLVD0XhH%wrApB53=-?rC0=N|l z5D(sLkfs41ySE6xmcOF6PqIZ^LL7K!P_J-Egumix~>thy@>%7T2iaYFw;m-@H|yaw;r!T~ynbs?)Zb`AwaAuIbAF5_yJb&YIlEZ>|Ci6AG4$A zwfx5AgATE(x8!;F#QshlgD0;a@xmS?2px<}lV`*~1|CZ>7K@D2h~Xs&z4>m1;}IZ& zmClASzXntS#3CkQHBjC5_YO_WBL(t;Py`(Xz#-HN!F3K7yxrTgaY3Eehu0zjd~kV( zjNzwyir$X~kXd^0u! zhl3nVr?<0wGo(@A!keeViQ7@n{~o&j-l!d47IiptKGRs5Oqx1MNUK8*7d z`TPtr{U=OR!ijr(;-HnF5fi(zRA-aQM->~7gdF_BzW4vP4&UCrgPo|BYJ#qsOAP1R zBelkV@a#pyjDcQjueoCm?b$5f$BZeybD5ojLtf?U-^;=g9s=O!+v!g+ReV%fLH={8 z|DS7}y=!)pqHep@Qr6>DhnT_Lb!z#6TdTe9q>ipv<>K{+Tkrm-?cMhGvC_KqD#$2T!6L~BTAG^QTLjkYVX(d7vDrx4pFaZ* z{A#Zqjdo0jr~ouCw3pGTraCI7C`V)xdbZdjeIPGhkvu`@G;I%`{o3peS3ks5mrtcO zh`cnv-@g8q&tjp^(YFvz0LB6UUZRt* zaTdGGMLA!!hr0X1H6*l1%Kzme7Z#E>MKJP4?P-CPD5#zWFdyZ?mKb)TSRYu!fSDEG zMaQ7tjB@%REz-T&BhJ;Tjs*1t8y{;m?(VbCe=dXJW^XN2?fSDX`0C`jK0s0Z3T#C% zsI27Nvvr4-7KrPSa2Y@O`YA+vk@@gbGu>YmZBy^ha=8P2@=p*6obeKp4Yn9NNZ-Aa znpSsxL6&vM@aYUv=xSQVVqs&BrFcerj!X6lf85a%@x;520M7Na-l%n}w~q)Am{Fu+ z-B!163u=F67KngAokZ>g!*Qk{ZTyzT399uaQ@l>U3mF}XHfHQ;$0RbmKga5odooY3 zrUM{pvHZcTLy6;mX;l5=DEr?(2Ylz6DfkE>hA`rt3aMmh(ieDt z)!yFyZI~_a>pIX|_iChQ)XvOtQjJl1D8S>XsN`OTDC7+N`m^eGj}qgb+pky}Ju3t| z7+pcE=99+4o5pmzrZN^JSiX%vr%RX9EF_Y@WO>DdCYoIGKHxp+GRRHl8SFHQ5kiOzfD zN2k%ToeMG0UM4wG_OQDSb7#Pr9do_qOAx zk~XYr8Kx*L2J*ez<@9D|Av#F#tMRdrdLhT*jH z-5UY`M$)+nuB!4j6bFCd86$LL9KhRMk$tQ;Xx(7fA{tG9+hBljTc>R59X}s^M512E zG^s>+?;11R=KKPbW_I_pvzppxnL4jx;!j{5;|&8T4?kCHZS4bH6|u zrZIxH&(R|*o-XoQabU27^YG7ERyN9TOz^GH$?!34E_>AfUg&OSv2e+r3!+g1A^+EP zUI(u8X8ro5Kf&e*v5%y7BF^CErn^AaO(>WSDg3Q_KoLM9PSe}Qf1AXfM?QFr+Uey7 zQ3hH$Ya6qQ&`8xPTBMAYEHW@%2ZYx2`yZD~D`Pc#svWP-<>j_Qzwv!|>&p7VxB&E& zX_>J-X%kC@gu|OZcXDccert65s+uqnLHo`mk{f7t;9w`ZWLRT0m&c29gu&Tl`#SA@ zau(ge%X9}+_Hj7HKW}W@Z%3-8@pB+JX+2*2*LKX9-I7coqV0% z8*dd+7dx9}BXmFB{X=2WUaIhd&bGYI&DJ|JCr_|w@Z3X&|GG3iie{|g?I+jvOG@wk zjlYZ);G-Om6L1}|+?6>AGD#%_aA}y{Xl#4WBSDQRP4+)6Di1T)m=!r7tg~$=Knv3u zUrj?XBT6T~aD5#L;a<11!h}x-KI*VdEE_sLULO zRdj*>bZk2^eu7?mg*@$tr+z31x8e9}x;hEmIuh_f!@p1X?Fpe0Bkiw~WvJ+vcuN&P zxm-xLvUyFudj;(48p~EUU|)mC)}7*yd5^!sT6M3Z0*U00g;wcRhfirxi%{vlk465N z2-c_t)p}YItmZSt=h*(^V(Zv^son*LMdpf!nqmXz$o=x7tUH_UPHv~$sLJnHyzDt) zh$+c+sIB;wBUc-1v&Z42Qa;2z_`W8&KXW6;wq{-u`q?41J4)3xGB?*`{&6Vv&vWn6 zf};K)so~*DMLK$6UHI9SEVFlCn?L`#Zu)qDA5*rM$gfpYq&5wyq!Ufl!$tEm-`Izc zKbyw=TcxR|7&DJ%QP-d4DC$5^=(7A@T*D1u1ucGwCv;DgbhN7PmJ=Aff4~Htr0ZgP zyZGJ6SG_0d6W5RJJIp!PaA*&Uap z>g0n2i?pfw$27)XR(%oVkWDSJDbqjy&Kc*|Ck+SaNw(JEh+!<7_hbP>9o#(8-zDo( z!qu)TkCJ-{VFiswr|zO0m;GyB*UK>$=!|p(*YmU5se%U@j*QzmB8+``!|eGI;$=U3 z<=WUW)J)-493VQ_&rN z+``<$M#S3LqaA5KR7B^yFxcF(^Y^b|^-tA=-|u^mL2qZeGF0CvHpB9VX+5+OEcL6+ z8zD7V|9N|nQOj`^KI+Rbl`Vxq2hoggyOpDJ#_rqTYw`}ix9+`jz)fsK|L1e~L_nvA zmKpECM4x147`lF%=SgRQY5s2J@&dKwsXPl7^D^7iHu#jy<2qN7YUwYHk9_)T)} z`Z=n?w6-}~FKB?mRBEYA%SU406*8olB!aWKdAW92n{(87>DZhG)swmOhh-5ccvf0R zXt)xWgJ-dirD*2G%YH+lZDGSqTf*nUmysTg)4T4;{7#LzRLM~_W z5J}?*NLMS-R!=A3=t)Z!u!?A7257+27OY9Hq|T!KLIJwSay zQI5+kU{8FfW#@wu$xSUH9m;;o@&mVea>5ts^@dzXRMn3P)38}iTJ@jp)F!K_)VAd@ z`xWlr@oZrq%9L$w0Wazrh6k;rWx28Bt@#*rQ}=`Dy6`ILEHk#wxM$cJmEEwdFI+<3 zorQgz2jjFQcJU`Ey=Un(gUT75Hgw&8Mj0SjJ8`VHb3raQZIezm6`7@Ix~3p@s?e`% z$g-9Um=hb>tvvroT|(#AI}XH@JDHTpv1`ZQ7pab(MpJ+dyZl#}pa8G*4)Tc*^mm_p zb`8h+{;ZT_J;$PpSV>k-mez%z>*pvt1{jiJ zbZ2`wX5wOkQpS&8db6W5+DLpjNU}D6eG(dd(Fu>}0Vmyjh9IeHtENT~?hT%34YFs< z9#T_?K|h+%xgJ*Fe)u>xG|V&y3u56ri|B%dq?BX*yDP}1ukogLCpdy1%K^3Zj91#9 zZ2TtRL@Ye$0Th$3+K%1kGq3Dw11d*Pa!g}$H5Hoh;~VZ6_1R23dIV=3K+b_D)7{-& z#^0&Q=I0Z+;h8tK{ie9agz7hKMiYa<;H(tf*(y?2Fp1<$$9&k~O9e6J`FFkDm2&EM^0f zRGjG7-L`SRSQu{T3vIjZ3?e9U+?37YPavJnk{59JIo9ZLi)p55XtBGb)^CN ze!tfCxU)mMUr8@hOxll|q2@|2py}-$2&00}?b)FMzx-F`N{tLm@dKH2rB}!Uvk$+_ z7Aix3PQA527C5)`$tWldjA(|7FKV7nC4I)T2Uz~}IL<($72Vu=pqI`?(rypWA73`k zt}EsYbk3<&0IuRq3X5SWdHz*iX>Loz^5zkRk>O9VMTbur>5MN>yx&BT%u3+Z50KHAMB^$>+M+uOqq-sB0cZrjNYQ~zvST^#vJm|KP}cp zL8sh3oHm}V5OcRESD~D0j@EndWinH_tCQ_>o*8g2d59SDWh7=y=Cwf&cjmQogfv7RGqo(I@ z{gu`q)7;91#-T6c`2M9p1!p?TNa1`SY4dI2b%VF8nHBweUJe99aQpt%oBp)U8>P_a z0mQz#z$@q6!@Cy){K6Sx5s$Zyf1GFPd>k0*pZrY65JF(Y4A}G;18!!Yc)~^9b>E+X z=FnV?A0bCCys|*py}PUg?E203_uURqhr9#j0|Xu5HJ5sEsb+A`t?0am zTLOMGE5`altCIr$8)XMR)!;1+&`aO6x}RLQbJSb9rdtk%`wWgOn4$u`{CnNbpNU3K zrStaGn6*W?%$cs{$4pjDq|brZ`-C6|VmHH2m8cq!T+p1}o1?g_ly@X`?zlZUxRO24 zWbkSmM)92QSTWFxjq;2Uhaw zc@w@fNV-yvo88;?kv1}V;=7zvji_0(D(a2A>Jg)`rsvWHXs%1AAZPSN+q}`i146~AQ_`Wi+&z1nmw-FjHD`AiUiyF#MkD4r*pgc zCwAyY*DwTIcYZd42_TTKKe`#v$0ujrj8@S0v$4DNc|SK^Xf)j6V0Rl^XT^PS@_{o$ zx0{+4x^m>h2al{_Tve&>Kg&K+#iW_dkzG$ZnSAX+{0pb&_T8>}_er2CJ2~(&YGKQd z`GEOj)75MDQKMgRsv9dvv&edeOM$7dH9jjL+P9sgGs*YyPtxV(QHH8C15s3yvTlyd zD%zmiMy+;WvW`R#*fO;mozygvCVDisKzp&%8=ow>;P!D4~UYs%R<^32!pQ z=uaNz%q%#l$ST8gQ<9E#)roHGKs;T)a^2;m{6H9=bl@6?gK$pib)6f$7js`-4a?Vk zU+|2q6*F1l_49uEjsl`{eb(AOy7WMBw??HqkIDaca^7cHJgxJcFk!yff=24)_OS@# zn+k#!Ca3Z21*=UmrYr6s1f7_U}c(Ql=+J(3;i{**El60bGEj9B5|1gd8n)Fe&anpN(Q;)640hg0bta_q%$h!HHIqXt-8gSbs3 zYRZ_nbl@5D>l9A~T8W+GYDtaoa(G<`s7SP_ldb&|XBd!>n?^B5*BxAR0Jh{8IvW@_ zrw0Eg~+;To~ z2G_$~?pRpoIJ59n5_W6Q$m1cKb8~a63I3Du{|w`%HVOcNfG21}A`V2gwtg5y5xql^ z$bh9i$%eRuH|dyDT=dJ*+j@B%z?QCun;q=KQ$cr*(Ol8FN}o;5-Sn}wPbB-Z+nf$mOXK}xOPgjAc&Z;$@p^uu__1|i)>;_fL?oXT8}%FE*5tKWKW-Sjk@ zwGeNe@pN4Hn~16oJ(>g$>#aoCa7r-E=p=*9v{K6H# zx4h!Lg4}Xk$`PHy%9u!R`YvV$fwEh*RUL;D;PPlUi4)%FlV1_zy;O>iouG`8wv*Mi`$J?%S$774(OZL)Ey1GxENHq zun!py5S~VgP3z&*r4!j=+Ak7@h~Y+ozBs|}sL?0GSxIx)=7?m#7s$(@&_>MB?5G}! zhyB+Ln9MKS6~dC|=h+VhQu#GVSr8GysG*|CrNo%+W;uUxt|!j?(^Vfpc0Hb5JRVq#`RuF3_OI2e53#K@pD;}75^E{ zlyHkPC$}bB?4pjh6G_bRC!l)Pp4k;$j(-Jz2wfa@SQUIO(|EGGE+iX3+E+%EDjkOZ z9s{|1GX5%cx|(LGnLh!>;R&rtU+8~hThp!9nw891LBc*|^HF@za)9upsqiqNmZo00yYv@n~M)EqLKO+=hTA2z@hiX7{%=ea!Y(zpI8f z#P^kYk;?02_3VDC4*o@7V%&Br?Nu1WhPkT`D^hd;00#bfXjW|*Z#IdIl@_rjauo3agfudOS)bqZnjXT}SRMXH z=v?QTO;By&RT^yazX-OvoM*JL{Y`Q}G%Tv?8BE}No#xQqx?n${^Q~oPCbaYiy^YsA(f6Au zO1t;h+L>Af0_vVjk&@9J3mWBxCp&5p{2{M4yhO{UQitQF8Ce8k>siNTleL#)lHKA% z{2!}!sh{@Ner%WS$-UNXhK&8Z%WT|N)iCh3U#Ya_-C~4fVM0myj!V7K$c~?#YMuH* z1?L{Cd&)+>vB3H7H@RKF(=yYa)Is|)1S6q?!p9TV4%rdWaTazghQg<#W!<&=XJSQ! zFhk#XcQ7Jl%ejKN(>~w%LpYkh{zRTbY}oI<_HuX9uPi?&6#vV*(??^RM|>6T4Td#+ zv&JnQ{Cwwlduz;Ky?J{HK3_?f^0uR8p0J!*sQZWO-YF<)=e1${rKjl@9Nx^{e(a55 z%8+>G?o+f4=|6T)bvQB?Bp+OTQ{bM_B8x_MlPXsf7K3Nux2fY8)`{O1l>y)Na!gIZh)ZY(ZbnPmE`F$OSnWo6U_U(unWgA88tsgrj{1(q3 zaWQGIB5*%8f8NEEDr(OG{kd&qnzPH5J_#oU7)vM~{F$*m_zvHPyaa`e8`pg-#$aa( zQ!rk$nt-zcg7m-Nx(nY&5H4k~QMMyxR~|t5E74|)J548utwiCj(ST@7f~CD3JFILp z5|VFW>vFU>&L2dZi9|XgV|ykr&ppcu@ost4`YdlzebG&kfsDby&NsvWTyhstm2qDh zXOgi?lIxi6$=E|RO?7!fUve+-$U$Qhha2A`g27F450+CJySzq&1i=o0_*?v7eZy&L z)N;>%7zFf%t{SLlGmdQ7KR7c(n=Y|woG*6a5z7| z2`&dGTS5{N0st^LrPk*Hlna-^kFH{>Bjf@fPh7j0=*@d=ux4;ecR4hkM_kA8<_-L{ zOC3n7r~kcf8?YD?hVykKIDeb*x`5`+a%ab@M`jezmmp*1y?N9A3#&PbQ}#V!j(D-M9fpaeQ87It~|(@i8~7@qbq%IH5*Ax#lCAw=L`Hg7II_2wsKa zmqDPz-9|=eEyNr4JXfO03*GJXejl=hxpR&GQAp84gyOxv`h(pn{EOFNVV+Ulw>r;F zeaycXTHbIn<4x~nW+zPXL?Lf5Y?CcUn|U)#bDKN82_x4QH!ycOZWm0iw|8X-aDmGa zvO|t<^P9q$i!iIXtDli~AF>)&t62>zadyI`T&M>ZBzU{4j5nUqU9Ss6HU<-l=_*Ko zn(@wnoB4LP?aE?o)&JpMegG!0LYbYK$IXIAE&ju)`forZ z6XFTr9|R?dKt97^-YWr!M3nL_ABm@@de8Gf1MFy4aJa-^LS*{Hb@2=)jJV+21JRlP zb*^2Z@4Wi!|M?J#Ma8JsJ-VMaVO-4oZZfmP#sZvlg9U{hqVbzQ=tx?@Led?X%%Jr8y7IE#r07%646THlX5w3@eq zXoe;PLtvgHXB-3!UI{2T6CyjXr7noxe}8L2&QNUS`_e8i=#TU>2_tZ*FApZ5R;dNC z5k~)sf3TNe6z--DFA^{eU`{3w?mK(Z9>I+a1|R$pq`YBh5_T6K-#cEQW6kX%2!I!w z21G~qM9A$5)&eu1F-n|am~nP=PUCJpz4VZ8={sDHaH4>@Km@B*@gbX+0$Ck&$TleJ zLatINcknqVz;LdFu@ybXypKHmMT=R1AO;z%Sj~BV=9% ziwiYEVvIC@&jPO#0zrV~T19P(hN(r(DFa}Lo;{al$$1PCJ;3Hae<-Nun zKgcaGwp?#3M2SF!!L8>btj>Fe2?^^4SWhH@z>_f~st>(<5I^8LU#9=zhK~dP6aH}z znKv2*CUM2c@%L!$Kdl=Hdlhe)Y&xlgS4)1-GP_hGu0R{;{LfX^!-avQ0*jhH3Np~x z=*GcrEf&ZeqohNIK#*L$KjB4hePByY-QnHYWXam%=|sQ8A%D9~_2BY3pw$1jPG1y; z|GYY^1Bu{z^{p;RE7V)_d!G##uE6aAu_bLYM<~*JdwT<0!UxA4^KK6h$6{e2t*sS; z2-ea+-}(|Y5W)NX#KW53z$7rJ+(*<_1(Tm$t7LJyy1N7Z_R1u%7Fzs!UH*Us_aSz? z1tW|xmdT*wm*Cnc_x%Sg0LB;Dk)xPla=lu%=Wc(O2>@S|11-feY4V7ReG(F}3F$Qp zkBgdlU-ynYF<+FLzuQ&Dn6S@ggST%A8#nBK8~b=OO+%$N2cu&%HNfkqCG*tue|9>f zR+z}~Tpl3g+|v7c9w}npjX9ebJCkygrXKHK_UfXaRx`2lMM8dD9c6xC&y(ErgeI~C z#*LPXU;rz$oKrqR?)1Kwn&2ZDUv}b^q5*pJ23+SU#xcC>+@!fvN3FAI#lmIx=AB37 zK~e=I1dP734FR}#IcC%TIVHQ*0L1I9`Ymmr;d#absr99U`c^1MD|sy+#S@#T;~peP z?Kq*#v17-bRB$@#f<%7!zW{B7edN%w>HGqS8MT?C-*iq?*qPPO z<5!N{lZTKfTFO$V(4^Bie#*R66JgeIE=8VHH*0gUg5Z&$f<*y=#rA1ly>0n&hJj7$ zc^vCg4hsD8XcoGjM=1+CTKBMTGU!HW3eotdYek7>4rIWXv-Y$3g^h`p45IPKZ4=F& zdD}v1NRvRvMa_5y`7l?ws1#+~bVrG?q9*H-p)|ar5EM~pq~874hLJ+>5fM%Z7^s)) zD$ZxCdNvD+Myb_gRpH@Hvvtf;x9A85aT~eLJ937YuEzWWcYx$a*}m;L#x|a@RW%U7<;{+1m445 z%1`xdE^9gY$kK$!=#vcBeAiz!BAbfcbGI(@xtZ+Hr;#V`boN^JjT{@g+&(gGJNZ)N zW<9@vwEX0>&FVI`bKM#lWNHTzK!k3d_UcH+&SmeM$WjFBDMIiG8T%OUD^=*zc72iO zGMo&c{$P#P*W?6Po<$NoO=CDZs(W={s(67K1!}^)snVE>6tBd!SDO9*jN(nIE*JC!5#B@$)17^>rg1I*!tJ217>S4U37H@Ruj46dac7MN z8pS^o@;x2qyVl6YZ~c^~mw&`kO`jNTqj|JHa#MVM!P?#h&o}T**38$^3}KF%fK1N( zEj*lw8EWaU_bvT`KmE##ubx)rmHPGm1bj+x{iXhW3?w=i@12p% zt%*?Io0c9Ym!-k^#_?@Kp0f&KcpSf3c2#`OMBVkOAl-`(@dSzltDA`KZq$K6q7Ki9uRhB4Nt-dxe_ZtE}(i|jlCK>48> z#^S`6{obL*vW$LB8Qizb@cbB)hwYN_h|H+=hg8;Y|BZKhviLrX2hc`eJXPq?0S^qM z{ML;mv_ZfYJf9KF4@jmRi0MCI(hR@hqS*=AYpp@wWx^fPSA)L`7y&4f&}a7h4;nF> zidbqmSiGb2{yUohqPTBfDU+JBwW<~=sA6)VJ2`<{M>>co%CK7b>()tB+Uu_+gX~wl z$HVw3n~*2ySE1boTn<*afDQk*O0 zzt1x$ZRj_bHs1u!hq8!dtpdEzwg*?Y)%t9E@XGg=?w3}l<1?npmiv-*@C5LG?%S@Gnqn0Q3Qf2y@1jCY6bju0+}V z12lRT5^R&({NK6eTAtj|Tf4UT` z81VwGA@f(_wZ?okPx6&-pjdoueM${3WSIW6Ak_CmZ5bP&Ohw0M<|Wt(BVuvH*H2+q!t{LMAJEW8UytRB&&S7OF zL-Rb>sZ7WESK`?H*Mt`WR%$^h?b)e>hVgo7q~`Fj^F|W{2JKz=Ueca)D%_JCyy+vn!iNoP*6XNuZ^dBFAt~ElXQWpe zy5@*w##P_WyjiicV&sbubIV={`&WbhKyR!*Y!_3EzGB@sFDHEWUz4d%xq17Y-BDX` zO7&0OPosUOv!Ibx(!b6y(Onl4Odl8(bFktpHlrU($9wfO=1aM;t9reU3j5$}mZ z--5%MlndNrjc08V2UuC+Y54+EvS^E+O-F_%aJZXRUBy2hs1z_vo$%F;dmdnk);bs3 zS(wK-QMAX(9CgFz!-(7#O~NL09@h9G$(n2g+{6(%8SHY1H zr|-|6MPb|r{pH(#-uG_L3A03#l$Uz3u#}v^WtOEiIWQHjB+;m8f$YJ)Ln&+feWa`RkJRi-;5xZ|j>>=XKwJycIH zrmedNVYlei+^-_adZe`wqu3qmUtUKMk)t69H{Evl*VZ?mORUY_U#|^HG=US0;nu+1 zjs7>=2R@Euc`>cfR1+$GxM)oOks{82v~r^^m8kYmI`xR|rn8|}n*^;mTG>Xm`Eq-~~kUz1hYEg^lg6?OkZ21ukirV|3JTVuP>t|BF$9s+LJq#rpt`sCC zE${YYK02rT(?x5W&y$pJq>&d^R`a|&A-kstK8{d+NGh*!%dqcdtyYuopCY@V7gX8e=WJ%>W|&b`m_sKjeE$ra6q_y;#M zpWA(NqU=X&zM2|ZhN<4=;~+Nf%zF}DDv_8KjnZi?pQNBlB#lIIVfSW(ueD54+QOep z+qV-vhYO=j_Wl|Afw^mZ7U@_yd`v~0D7Sv&*-CG%nLE9UywHk66&&Ds`~3eQGw>c( z;h~|S1xtmWlgsyK|IsZ6=|Fv@E~;MxF#P(DI+?ki+H}9==AW152mUAIVqc3vEywRN zI}Z<!RX!U+_L*^BVqK{4dOf-xPsCiTD109t67n#JRwyMGfqT`tEN3UY2R{7M&_?8H|s-1R0zS zi1fQhF#h1sDHUh5tY@7rx_yY1$;cr0JQn%O%i}blNsP{gtc1eI{oO<97B=(8I02ar z`SUeqh(m_2B`MEeOH9BgqsLDd0sU4RNQK(84Ykd;MaJHP z@X&rNG=&kqz|iD!c8#jTo>M2&_*B`y6 z=(<{vU{~Ao>RV8NSPHwi%yB8wTnVb!rT=3!9pm2@2ojx7jr6Fbb)&gpoUX!AD{dpQPZpn}-hM@!6pC@&Ry_AXNdFq4~P%(B^j)ka`+9 zA9v{ImPbKiBTZ1K?`8#e%ZKLda1v1|Rn!-P7{Cmpx!H1bj9-CeOL!Ls(gw_tgxYZv zpu}O~0B8={_I9x*Os#|WZmwHTkz(|**a^hA@l7k0hwj~UnEqb!6s{xqTTq^MOBlh$ z3_u_3jhM94MqLG&5Qhya9CFsAWQ{2MF>`Xu5)#**DMcr4zOUPi4^^vMr%NCXT$($6 zQH`#_alPR=)A2VYQ{P8NZEn&?t@c}U?r25kD_m$l_qX+tV5zcEoGFm_j2TB3ZA-u3XyrYk{0%!X2s$NQXUx({XN5aFFapdfZzvB=b?(KpW?FgUOkoiWV=evRRFkO<+&y&Be9 zJfCzhT(IW{tH*A?QajF%Xu`lNsZKhZzx^`4?wj=D^n1^LmWppTxN z;=eHPQ9IU>-Z#PjqnxrhTG=z?J<`Iey{4MdvJ7L8VGi_lATyO?3fO!JB9sXo?{zMV zQ8xkdBhGCL?6X;-M@tt#kpGU1ndJ_M+o!q!L>3fw7p|><`_(-FF9tzr=UE=dc+cuE z`K+#`fp6eq;B4?0eqU08gYCqQc`k6D?$8~ngXTLSUPfvQQWK8@P(v|H}zibv9-taeA zF-F&~8AE9et}EHd9!U0#d7f*=I37%_ee=1>C12YyLlDi)btV@gf^2nIDFg&LqjW6Z zdl4@A(q{&}^cqgT##x)C9y&Tz$E>kE-+j_JZ}{%myn&=ST|DELl3I5|LAC?3(J_&$1vXMTEC!q2)Qwq&n~LG>VdsP7!yCUuJpi zE^(^Yt+{P>GQ?+SrV8NUhruOD*tDR8GoZ-5f^Zw}cItde@Xc&e$1Ql$1aygnh3d)k z?Sgsig6L8fmh2%L-g)xY`mobmxHnYGfB)^Una6ry`be$ueJLDEukr!! zZPsX!2VW;+os>(fnS;R%^R>;agyT(6qO>nyF2d{wd$KIudXi2^v#_bX?X<1PaVZQQ z^{5R;E077I^91s1&%5pSk1V>c_*Qs^Z3($R0jS*DHUaihpBv&t#xliBgJjJ4FbRb* zqc@9_r}%T`x}f?5K-ixVEB;}OHx8VJKbBl6$1N&d%YmQ?1B3>02R34pI^GEt;};Xn zavk`k#G7Z#F4~S9w!(Z$Xz*pCjXClAPfPDval9&eHWIltTw981{g^rSo;e<>{%ogr|9t&oI;(h(|12ZET5Br5rAQv>;kY_Z@*}BlxR-{`J8#6 zRc@u2@kGf@-!+k<(|3w$;VV(7AnoBM|Bk$?X3PxN;oMVp>+i{RcB1H3_@{@4hZEL2 zPfWUaEjD|wXk2491*i;dXlEv3`sJ^d6h*3p_rSs)1FkoqBWfO(7LSem2tqvX`!oIG z7nO0FB&lVPy|&0qi$Vw^?Kq^n0kAH-LHAm(xm<4i;cqmW(VII1lBW&jsYbsCE+VRY z4Wb5&uLj{4#qn1M)~zvoU zJnzR7rB*zF3b;Bv8O!C~D4v_~mx zI=+QwqQhe}^+~s81Q+c7AAl9c9}B2ucu4s>0wg8o!Q;eomAl15d?z`#PfUXFSgpiv zhGSPt_^vQe!T8TWfjefM&ib&sw1ah@u&SQePi3c9KC-tC&mE^?GQW6ub__1OH?!wG z06iy`rxA#Yu5xQeG*UAl6lu(C%oFwON&W8KOnq5saC%Tl=^a|@9i>WRsiFC5VCA!X z`mynMt`2VPV5B(k9~U#>;AmjNh^^)Hf)rzN+SpTzxJNvP+F$u^dCc^u#VgQ*6WZ2r zd1O9*5(d5js(h|}Z2pQndL|{8sk)TTT(_=7p&*8el^%^C@+LWS;(L^+gb!p-T-iB( zDmwni#@bWaas}b*)YXW8E3n;mKJpYbDdzo8vUYa4qj-%--M!Nx_a~$QXv`0vY#$WCx4sL**rb?ij zTSqV0ReGQ%RUQ1hNLJ7mXj~+~96{;##ejQGma)sxI6~7?+^>UR2f7W5G)$tPsKYMp zolA4UZu0!-<;8+r?I9Nf(ZFxajf$_{yhPd$RyC$(_WYy#RQG$2hL+LxiTOfx6}j4f zA9fB|Ze9xT)8;~X2Q$M+JibwKYuOJ?K17VE2tFX4HibBPn^j%T&X$v=%xTKs2Bgp) zYg5z%>nHwy=1+f_=*5rcoWXLuEy5+B)Xnmr}6pO|ckX2ESHr~XmVmg;}*Cz>z`BNm;zo4m%o zx9fFg?2*uH4+1%2a*-(-G!F%(QogLIz+`!8s?U_%Ej9Eok5!;+`F?a@rjZdKQJU|N zJW$TYo%mNfUZ3Tu`2D0rX<#z8B*cpt`cBpWAzHKg0AFsz5m>yZrSK?fkACk9w@AhV z7u&B@`l5j!+v|P6g1K5=)Q!1$EybPqeOhjp9AeUkDt~gN!#AtlZ~aqTaZyM`Wf3dj zP%~-2w)UycG*e#pk(8(v6=xAsU)1bxUg;@C)dnKS>>VNh4|iKmghgbv`dk?Shl#;J zdw{RNHw$=TW4L_7*3gR2F@M=4$G`uX*nVm)Y`)J`R&_ zg){QMtIW>-;MOo=`EAs(<}QgJ=i(R-zHWMAsEqP!#X{693h!m}Lwo=oe4z-g(VSst zUaConCP(4ylPs|de6O6KDGj5}F;26=b>?Cwv0wepv`4z$y=)rXgnWG!3{_(gG&i*- zm~*(Vc*X_|yTy*PEaMP52AHN`i~^#fpMG=b1w)2+;yaR+v?0#r6ZI%kEYZ?Nx3Q{y zq41y1f8H1EA6(H(B3Rd2ZW?(OFGT}w@k)bw^d#J5+`PX!!)3evlsS_oqzCpxp zUkcbddynmTloUw_CP6N+j(rMx(}gx1be}6=VpFN}elwh^+F7%Z5Nb-@vGtmnU@X9C zOM{R<3rWtYb?@JNc6fHPd1<2`4V3CbWY1D(%GpkPC@Ul^0wihz;g70f4w+g)!VoB; z3B5^}iPv2kB9xRr4MC>sO;kf*aeuzr_p+x0mekxOfMKIpLg zfViq662F?K8*|r~*$ys+Jd`=sSzt1>y#aPp1c%%slB71%o~-G zC9}#DZGK@jXT~RB4Q+VPh_;>7uKxA34|LMtLQ;86FsZZLZU2w4nGYuW=94k-qOnf& z4>^C%M{49rFx3^?&pdE`$ykr+PvZ&uYe@g=>Mb9Ti#>Aez6wXn-RF)AJG=RN%Lr^P z1tP#U#M8kVZ$NChi^xolGAkUL*>*Qxve%63bgYxejSegwm*jG{(I?A zXqD-QN6?xFdXziG2{LF%!<8tNuq z{QEBrKeLJY!N{UXsm7Q2ikTq7`Iq>i%TkIczko?0rH_K+&g!~`;6kGAIG2(M{rnG0 z;cTGV2r43tN&~9IG(V<@p-1f7)B=8KaxB;-{ny#>?p2q+ zA?xe<4%SEc`}Kcc^uK*M?qetD^l(_EAW*M^n-WImj!B$H1GENSZyy+xu(2ID0ILu# zgJiBGW$w$;1|5`)fI^wozrF)R<$&{JaZ;f60D&}*t+V$-3KTqXNvr}2@H}3E4<1Ms;w1;Se1-$X zwsyCC&Wi&i>N$< z4psvzX1a|~R5)5Yi|EQ2W_w@Zx?$ZpI6V|kuI_V(|6ILs%uZ>?sIn`FZsz9ZJT?94 z`)2sc6^fGPjA&VZ)^xU>K;j4j7*7$fP67c8kWJi_x8s(!uMZRdR->Ohehg2A`q1rq zknjAwAThck2nd|xGBemEd- zJ{ZgTH+7nzh;=^nN%_rW{$xO+IHP(E4?)63**jD&FgxK;4tgI3O0RGzV|HyGJ}1yO zwU5p!^}v%p?EYWq=n}nD2;it70{Y8E`8zGnHjsMY++`rM!Vbu}9Wbf^{(J0>u~z0? zuxQ=dU2;D$9&jgsSRFxU=(0R0zy=CL6`|YjdjL4*2XJOqPrSeOf9{I^UF!d(^_poi zBU;hUO|d+j#y~=;Hq8>T6gOos%vqu<94Ml;HrJ+?L0eN-?BTh-yj-x+$3RzscSa(N zw+cMsfg{=2SgTU=5GIL(OGp1}f;K6NH^^fWkve+Lw!hsfa?P>prD9Zg z1i*8C-S$!;#;9D5+l{G8uA0<{oi(XNQ-AyZ#4FBjxKK?C@7#mK19QDt=X0Daby1zLnPwH2;0V|8Tyjsx;?OzpUf5wTcN%HL6OF$9u28I28cMcpPEC99^aQ zR4=I^okC+w;xJfakbi(BN2BeD70HJ(fXc6Xqso)K|y*mHTYKH3w})^Ri9WLS*N7z)7C>=%H>AF z)_S*bzC_#0l+*2n#iB!F|E}fk+)SpGR0+=E^MFp~9QWmQv35 z50No0c}^m-&IfipFkf?+FL);>wGltoUG$mO>#kYZsUMQo$pl1EOi)w4DhfSAbfv;5 zM9Yf68~I>#8$RZ%pWR{U8BANH$KCXAww7hYnKsF_pIP2_ zYYo-Qfdyl@jQ6*x9=3QFD|eVWg*ep|HF3((5|6XA)%x5{RulNm{=VdaoQHQ51%FY! zwW?3r5<>g3;*zgEXKq=NBQDnKEHn*kyq7er2y6gJR;+Z-a>xWnKVrjY;|Q1~tokO& z>nv;}g0L#acPwxAO7G%5PB=xp=7+B4$_FP`d3Vy)g2@_E8+GXP;_-GRa|2Ns<_C(8 zYf<#NMl5vzBQJ#Ht7g<{kIf5P36Orumb;(s64|**$<`gA49l(B#!< z|M(0s$7#v%Q zP^2Qndoemya=M^sg`EhxCbOY!y?;yFw~{W*S8D@&jO7)^n|Ai9Vzcd0CD0?wdo28c zI!7sf?w1>Nq@26Va0yhDUZ^hDm+<}&!L2jiO?YF@@~|2?z`Ca6B^sUe9_P88D}39kAOb3Pd!F%x zfdMW&qoEiM&hPpMfIuHql_L`sjXa}QflLH-<{^EMD2ifR0N2Fgxh=XIcLkPM%L9t8 z;{6|9L6*I|cGMA;?$0+Op8rY!-P}YkFg7s55(a&z-E_3IWhrG~ke(lLR;13LFXWgTwECeCC#uw}N~fpQ125*(ZPgI)`;;YuEIz2#y2bK(``gM9Ps-fw zT6=7)H2q@ICalcL1gVDwBaWLfH#eufSQ1urtq$xOT5-@Ug+i^0xbcr>z&I-Hl`!@8 zbE~4ZSKUR9I6r0$-R-=B6jho#s=IkB<*0kLUB*zWXIlGGxNjf=j^V*jNS)18cuFB) z4gm-JGlt&1tl=aRm1P}T^r_pL^3d`2uQ7YYlqSp#i_)HsTfs9yS(;`$MH!KUzUOlS zBMImM`dSDb6DU?ta!05xSajd6kuv1UK>Xkz*OcBSh0=R46~xe&Tu~(21q3leQ;fRq z_1jgFo;#)tPT@TEMb`IBJWyyh<+rbCIud77{%5Xm^*T;X(p6B}il!T>6h*U$bX<0l zK5xaM3DCmRDU6uO;kL!wg`dS6#^wt4>fYoJiIs}u$#>}u$oVdrw$8*FA%+_139)h5 zu{LG!OZOxA=Ub7p=uYXVIPXgB*xm(!H#tEH>YQOMbHLF}1M$?PTkM@^UD8#Qv6w&T za$+Vy1Y9J`G#CcO8ZjlW(5t2-AgNc3owy^!E!E(7^MR&rN(Wfj#tmP0`aBzDR;W1u_nkI?RNSp8Ur)4`z*>tgJK{TP!^ zvdoIsQv^(~rWzL^is1IugFr9TG=#0b^yj6{)&zaAx3Ble3!Y3v61^P{T9abFGp*9K z5@x?>sh~t_%9u_Qvpnh*PkY6%WFnS2> z6en%vn_$vEJaQ!^7Ds55QYso38aEenT%CEm_INlySQ5rY;^O?S!a6V@Fk_9VTRUPW%j%Wg zh*KvPo!$m_cd!F-dmMwRt#AGX1%LMmE9dcpV04f8*>@90{l^>rsx2;sTq}`_IzkV+ zfGjoXfe}(7mb&=jZIs;pv~2tDp{g6M?=o*dO{IZIiqp@URwRY3$9D>YUApKo!*e}}z?5&u9 zXTw!j0OBa*`KDeA%8@5E>B<>KzYY2gLB92)Y#jzVEg8Le;lZYO=crN&E%yzS20ZLK z-WK}@yd>;M^-_wZxs{AZ@#=6|)yluApQx0jPjgdlo|u@oLf0>89rEB8A(u7n^7*FT zIjeoaA+pM-(K(*X{c?ZwiBZU+-r!mVs_)%;h#KNwj zM#TCyMnKN!<-ZJ+2Mrm^JsV zlu2elnh#25g#S-Ku;V$nczhIgr_LfwogOn)0nZ;_;N|I{xW4a>IL_}$)tVH{L*EIV zm|D!x!(y5#3u%tON3Ixj?t+KdV%29V&$#(gTiJfK1=#N*x2;|bh@|=UU3h)!HQ}J{ zDdKak`nPKG41YXrdeMfb9bi)OelRc1{ii|ngG#l{KD&7-Dk~Gb_+S-)Ly0;}vd#lP6J#y5!YxJvE~=7P z?jC%pM9e{@I3n>%wP}_GW0148B4)9cHp2$%CVW!J3VO?2Aw-_yKVa^tOHg0ThPbpLt_!et}UkJT`jV zgp#H^W)PZ^*oDdK+$zH_hW4caBr*%5jjNo`UBBgj6X5Y4GIw(8`^(`766*89&dE}_SQdlt@>%8G16|P zkvB7n5qC#GB>0q`W`(87&-?KuCv_qs0!J6SBD%EnPnshJ|EdOj3W*qHVZmC;8|*Z$Q<(IK6hEznz!8(A%m-mu1svp82ErPWw|u> z-3_MY13&^MlwRdQj0Thus5Q7&jw7ZiYQNbwPAI;K zX)rK?A**~svR@~=Ur@L-GV=2-#e?aTXFh_wrT!7;GFA|X#tbXIP{n2#gD?} zlefHLr=3e#VIKg6SeSY2j<4lUS-Pt_laF9BiBW^;_4XC8YJ)MMJUAKqQs3Sj54aLZ zJh3GxIwCO==6@-`?y?d6EA9An4OieTp1cQ)mlPTHuE!}wqy?&52aX91Jv3{5S=Q5rF2Ikh z6e%GI#<`meMz?z1vm=z5;QE!B@(*<$V6ygi-Nik$@d7^)+^@)!KjSBnr#A%?!4Nx= zHSx3OUs_9V^~`%)7zk0GmI zb#6bL^X!r4SX*2|V!a-f?qZ*R{-ve?x$gevi4OPn(38#h4@s%A( zO-~YwvrQ+778_kYwJ-Q)XJ~Nnh%U+)JNtS(apq(4=g?_y#H;Hh9ci62Rg;*d;!s8O zx7oS*d7o3l(Aq=-gH;ThCNZupKq01=23FsibWGGE_VdKQnt#Bu7bQqFe}QZbUct7Dz3U-4_j{3i*U2ycWHl~Lt|emO z#(LM2mRxG;%Um5B`>N3wos|jozRfx0Jq*Up?oS}-Jd4{Q%Vf1~7+;6jzv(P2-+bp{ zw@zOZz2vL%#NkluRmGIbS@bJP4R|ShQ6i$+L1Rma7NbQ?4bH~H;!(v@^5mfLC8+iLMg21C6T%gi^?ksZ$5pc3Ww11 zlrkJ(fc+ACe;u@@yLRqNbvl=TQ-B8Sg-z?g;NV(<8t-a7Tkv^QOi4*tC`b{%iMe0u zEP!e~Y-vsOrlQhW8d~VI{p(cwjRethT#44pgZpi<`3K!*=HM2(z};49l?HL;C&7r< zYTV8SJ(7wo-z&UJFY!Xm>Td{yB*ryTg8z)M*sTySZ|CYZ);t$G6wQXwwHyhw&rWk! zOZ^#Swr{{WJ-3KG?>v(1pMEC^7duV3GN1Gw)PFh6`Nm)e@ABd5p`9>a4cjfC_V3}m z1Bebgz zc(eCNBoGJQ|L_8XEcDvqT5Wk;cF~$i;cRn#efZ+OI->b!?2Z%Z4v}4ymqY*V?q*G~ zymwGI4DV|M6NQy~SK&?O!3qa9nEcJBIPqKOrVJD<-w$pVy(Ey%2t0P6^Dx=732J+d zo_05{TEjPZ-Kuicw|S=Z2C)W5il^7sdANc25i842nQQuoll2XX(>)TdTP3}CBkBs+ z_08VZNc#!>l&AFfV4hK?b07&|--|_10&&thA_tO+>MIJYzXN)$x^T`bF)wCt9sKUM zx%CSBWM^lnvMz%k%YiyjEiF30f^eNsJiM0_b$Gu=7qiKUFoV9)U9YSF6EVl*SPY}n zDTZ{M`9xp{2Ag%U!omk zvNgqnE3b>5_T)+Ik3);h(oe_M+nBrF+3OSiRDScG&HKQ0-_BylgbtVT*2GHZy7XS$ z+YU7#Xb`7N>472KHR+~V(H+}pZz8&AUV|f2aFigLXuGTky{G9QE11CbG}c_y0xoXx zCT-Gh#7*ta8>3f*I` zw(2@@a;~wJH7*5qZm>s9b5u}NsG1I)BO**U>WT}@`*d@bx*NuSUR_4eUJWmm!L36m zp=yY?>nCR`5>Ip{EvCfkg&3wZ(`N-VZU`592M&-NED-Ni_Rc^^DTJ#rhlAut;hSX>jml4~NcvF;87~i5hgS7u0gTi{vZ$MZ6B%_M0)8pozZd3yw5MronxsQzN zbUbGTZwU3KWqYq%Wqem-To>jDEKwVI9y`5H%J5%bg+hvoN!#a}rVoww*0(NWfJi&> zO!k>C#-_HTagNx(GA<9Jo?sDVZofNhpqZQmoqWi5d-wN*%r2!V>35N3xO86QuP6j9 z_}(r=?yyXoe#lRmQQPzH?PPleU4@e-F-_r& zz@Nc;VwVzp7q~HsT&BjrUs%-6 z`-6ddGQc24O6QDv;oN7>gCr93#HpgrD9{(Z@i=xhB1^_1Z_@wBuH3*3EXRqnJkO@r z^0(P#Fk+S-)Tz>zKy`eneg2R%X&O5u8I{#P$#?-%cDGhKO&%MPy^|KwN+t!f#iorG z*?-SfdusS@l&W-B7!7F{K*-yB)-UBBPEDmZCe`RynlE)A4i<>WCn$4n-%~%+;m-Aa z3lXzSJg9tJw`ogTjpJ6b=q=?^HGAkY9_jL}HBfCCZQ&+S7 z^v$b&*FH0Obr>4bg))1h=r6$5-Jiho&%;dUovZ6K`+naS$qw~8XzyrjEKYQlnrRuF zev&Aubqv{A@%%x4~jt|@WG!mJj8_Ue> zMgL@H8X=(?V5bXdL{1-BRZ6p};)INC`98+T%|E32%Q&^!w8=F7rY~y*Z=mlZvpY$ zRrIqmSBzF3n7hQ(OvTde7DO|%WLN@+oZF;n;XcC@QOu^GQi9-*Z3EuWUg}Lyg4+JZ zM(vNCn$?PR!Yvcqlg86my+fVZ8Xp)Av0aZ>HWc~Lb@|Xv*uG_V4Z|9p8vWv5Fmf~v zI<0Dt!JL5`JnLq$rA=@lKr+#|0mA?V`nkZ5 zplbrp+ATq;J^roRuE!6HsvG*Xf}ti{e>k}yAz~;r{zU-*e=i058WHOh1VB2CSS_q7 zWH3O4fUf9LGkNz1|F=DErp|TIz=2qrcGwffhP7JfP-ZC&?nBzT6K*59sG<3SDv=kd zzeHD~ZeXl-jmOf{4zn%4vT}C#`A8r!RO#<(WC;n3+4|4^mqy_Q8`po!9i-S`$^ZSj zzx%`g$Q8E#eG}G?|9^hAFyxr$$uE@ zZ`5{@C9UW)x(CG_SWBT$#smFVrEg;MLj z#PW!!yt_i8*a(8{7*3SznWk!DNfHtedL=%HiO4g7PVnSzR9q2x;_8v|lC6l}MbpDt ziLhlF0m6Xs9@H4FY_#oDne1njAPWKr4?M46Fn%fz;|uLA=z2v+K-unief$UJ;^HOqLttD5653_ftH90M3R1{y$SgdxR9?|FfZ=Qm`c!cWJKJ*9 z@-H!gw0ePuJozCzXAfa+{bz-r@enZH#`bjA|5?udtx9J?LU5~Jr6qR2qXlZFVhc@k z@DHF{J9Iuu=z7mWM$t=fx~-+&LSq9`9e%;MV5ruE zG(xjj_^T>}taF`MF!F0T2-CNtR4-`SZ?0i(CBo?YS=ad3(9YtUM zcZ49bhgF0GexB!_K>MS*a{;5?A@SpgwZx-hd9Pg(;Ew6<{~1C@#TotH%F6B=zo&pY zy#mUKK6ii3Jf1L?!~+bCf}AWni(~1}U_O%3L{Z`lyC%k2e|d#;_mo&;iw&w#ZZ8tC zFF>d{2pJM)DRQf_io2$%qlw42!&@&lKbkI=fwfx6IWGQUG1a(7ge>`s&A~(H%Wj}Y zsK{5lLboYsu^lYDn;{>9YADPfF zeaSfOPt_WE(e_q~X9%z<-HtxHVC z^>wJaal2C2uUWTs`t~)JEqm50pVTxHlWNaBjwam9RQs9&$9k4W;8AU~;p+VN{g}G) zG$3;D%7U|vpj_Mr zwq$Rsuakn@a-$CN1wQo9mMvXsHq4^;D{E07fcDC1% zM%Z&gAh*bUa}C-DLMLq?`tF!w>xKi*@) z6m;X12)AISM-|n6<)>tSApMr+fT1l115F-g_S^#Fl|sMk&O#ZAo+7plRTG5#*{0?j zHj!UB;&kCt>st3T7TLMY-bG@A%*CR~;VvP^~TFt1u<$C7?v5gb|y@Dfo4nV~}^Kgejr;tF0nBqT#tU^ii@dnd~F#m4RY@ zslP#}n0$x6`?2}q*MC#Dkj~VbD&O}L)6SdcXuHD+QIQmH$}QS@t(Afwfy?{axxZo( z=YEvBi=;$)igdumBISA=+x7YLf3QTAp;{hLVli@LXy{us9(&FwLItp=~V7j`pzWz(2 ztAvo0f2uTf#B=%6ns#TX@T=0M+1kT5Vg;Cu@I(f|1Bf!DMJn&mxvF^8t>(!hRPup?md-e>2DAHTE ziNN>UG`Ly~a&TvS!yD{8J8ipu*ocyEoH-y{GO88fe>6b?<9HmO->E%BiLq-o zd{QWk5pkQ!6WvZ^P{M`2!)8YlS_VOiigs%2p81%nNb^MO^Nlga30P@x#8GEW(-hy} zOpSz*G$zWtSuf#(&5|Uh`h!V|{aHCs3inQf42fiHXnn~}h z22nBNlD>Vy;LlucrP9t^`MOK%2$QDgawwCDt=BHuuJ49nz+!p)6z(qXbMyCsqRv4G zs$Y>+1#kBo0;yGi(Dck{ zrgsPgB=|b1!QU>&A1cck;DV4T(t!k@N_Cu0CZe>24nwFQqk#Lh`hKQnp5dy0=L&(u zi!l?(jC;&)mk?_m_(lQ8mtXRFASt6n6?cg*ToN^s=_+8n>r-wPy{(gT&X+25O{@#= zIOKV|w|XF4zrE$DY`>$Y9Pf`&D|$;#!l$^-Bt<#mkDnt)NkDfPVVa5thWlGnn_lA* zZt_Y|m+9mq(FkFvtJ$J5kVywn(xw&-G0^ij*6VJRCby?6h}nMQ`-#UNxjp_x!f-bVKFwn3|!xQ1)b4nG5 zxGn@nHZgem%g?WI-Zy*S8sVAwnimq-im2x4nqqBh~`i1MRw(i1qN^;1;@Qxh+8@GTL_1VsWflgUH=9Z zv-T3=7s?|AdU$@Fq8oxj#Sh|EQ#DsrXKFWeH+xPA#H}ml?4~Wj^!C*?lMa?83KcYt z>W6*7YYuIBXlHG0ZRgLzA=VarXes1pTg0`wVt6(5i*_ffWHZQJEobEl^GvuYsC7=Ha%U15Y>SANLl5ij|Z zJTj5*a&7HiRKUw6L>wz(pBSCd1WZ^9rlIHeU>VF&1h-Hql*SQVRuEJ;ei4Fqp-IkC zE%K3vZ=?3Z!1l)b@ujb)#mw-&Q9PeCHwV^0b#R`T>F+zWI>h(T#M!DrOAN!K$kOW% zfB@(fWXw?PFpHi#_ppi85kL1wOM@XA4{Y*Y8g}IO@4Q9z>1Pf_Dzn9)J4JNlzfOhR z`0*&Dzw7y?#BV+$d+jp-=+|K;wVZhy7l6_N>Ta$yB?Sh3?1}WiUca{{+HE6HI#^WS zUn*Ww+S%*A81UTI%E3N=fcYs1 zKy2X;Bm#CyJbYX7OcHqo84#H>&5VGA?9Ry#!Mhkaw-foJcS42eM;opbuKvjqcN&dm z+gJVaRhEnpb5(HV_N8#|@gK6AprGF>1cw?#U23`L`QYTT7Vt^FV1GGDdo$Hl%Tmp8 zgwaza!hEokHhpY9Mu~SlOpj(-_FZ44WSUn865W@bfhIerQ)hHyWHa`ohr$?fM)Z;H zL#18RCfztEQo5|YS*56lOh&SlaTSE~1smvlp2&meFhQgT6VStYyiB+7c{2bcK^6-V zWb(qIyM&2h0v2CZvcY%uE5j51KHHt2C3_lIDMGmJsA+PJYBaX!podjCeCVd=s>|8{!xQ z_oJolo?$5^yB&Sp=y8#+@5~DG>GYU(?Krh=7bzg*ZV8y+kwBgCmDwkeR`2F#r@QwR zMhAwVsVS~fb7hB7c1tG0PZ+52=NB;Is1+=uM+HJl3bn+$DhV}Z5=DRd^BtBQ$9Q3OKq{;Y*%&~!mx!Rjz3+?>(+rB`}*Rx1)$BZH$UKB>DU zbsWac=r(w$o&ebWaU@vDocY_P>=`rU+_eAM{g4MFQFK#HelpX=;yv8%aKSXGNndM;MG_W&XPG~RM#$Gs6qL-mh3TR{ES0XJM^#K_YWmrJfwCStwk$^(u$lq%`qX;x zPU3p7fnM(q3F@b0PWA+;r|Yh9csUrufV9=LL?!)_p3KABK4IcwVu|cZ z!lY@-S9y)RT@9YbVAT94+)-jjv9&x*uHDZ2{YT>t6Y7XJq=>0UG8!RtR;fo|Jc2ns zSr$qUu0hWb;153@-B}TH!3BfxP|hI2bt8{c1fZ^y+hm5jz0LiXgDd=Iy@xpN%?%hmh1m6X~sTPM9x zZb4`7n`ug6e+oH)%&#&y^b~XoJzIkLGZrl~SJ!1k=cb_M@gJ!P2>lqRVMYezxvrI~ z2gAKrI(h_vQ#4Y8&5`gmrJ)1uxUZIQd+ds+I1x-2CkbwT)3Un|F9}V-m?d-py{{W| z_qLSz;9@o4ENxm%ug)yFuM%(F46oY{Q@fLW9-a5-=xMiD30C-@bpi2ZCUkkr^Ve>vee}_s)fzQ%s?fpX`$Gc`QiT*OcoXE&|Q8cZ8{0ZtK}Z~ z(~&*#ap{Ne=6dZN6iS1`e*e;OZ+rl;wM>UK1{?D5687!fYMmMF`bE+Z1G5|JX>6F( zk~h}OTv6z4I_O~#(NUD=7JIs}=2u=|=(j+FWtFvu!X%wvXSXCvCRmIfXap{YxFZDx2}R?|qJVRpBk9{hI`4i_SsR#syz6Z1@(nOVJWUmK@?1|$Cq{ptwgdIA zi;W(t>ln0HT&vk{s+N8{HiH45a(y~X$8=69e$79KYs3gS?OC?mjya67#2lU|92)Vu zsNFFAbY{J1HKr?H&A1*sFp70ZKBoy%h%E;rovCo$jiXo%ma?IO=CE67PA}si!8*@( z7rBb=Da-WgSd5vOzTdTio;&(-fVn?2Y}@LNF9x)tfNZ0veb>wnH7yv=?9(cJd~6f_ zgBAR&op|Wj&FR6jqn7?D5tCDayNsc)onYZmK6_qdSCxtkK1u+Mx!PqM_-qBnL<>dM z4>l9y+%2##UR)Au6NI4CJ)b&d9(K)54T=C zBcWws>`A*t4Qpxu#E3YYPDUJ1C)(&doqAj`x{z?VcppLSRO3?8&1zpGcp%Hy> zEjulS3KTJ#b!o!sEGX4I-_8mWo{Q|;1w(SDgNJg=KA!rh)gP>r4OW~&;ky0ZhUXq% zFz8rpw}Pez?5Ci8y4I^%o0mp6UO~c->g)9;)stX*WewS}^m*0QWn%AV8ZRuko)L%t zpO~1C-48N7CQ_3-#xg^?@w4!%4tt~P<`{MGH*j3+?pA+p7hvR#8+Y4(P?a@e+(;l{ z{y`mjwD9O~=g!^sOGp@G?AmNm(4~SmhA_E(Fz)!0tbvcQ?TSkyYgd0S)m*W{6-)1! z4K+Vh+dB-C6Rc-4m@`9s^G;3|+n|PC)-R;o;9cx-`zf;*GvIP=ap#Sz?;}nouXcEY ze^R+c1GaJ08==|AtV}w}x2fqWLbhfyS66$BND&@Ub&2~n&YN4M&VRhwRtO4nUZ(;n zsrcm`yy%3c!UCx;Z67U6a=tv*d!OiJ^FSp538XD2bYI0 zpTCjTq8XT>zF4faB^P23zT2f1*_UkEF;;T1`KhB1^N(ZM-bT9)RpHSq1j2ec3=+kw zB)si0z5BTt)itaxhl$U`KM9sZuD(jMflg?J&_Q=g1Q8q`*1t$|9ciAer(ET)%l$V@ zT~%h+h|2T~LYf~HGD1FS*e<8jb3o8It8tEi?wuhTX%obt<2^r!R=sL^BM-+%M6?|8ea&o?iPqQlx*`m|?06L>xSc-h6KBx&@&s3|Eb)&P#p zNuLgtWf@ydF{$(lsPH9Wzxw57SzhE|A4E`+sJ_S+;%qxIId^C~n~UPzf+0#ym~nj0 zfl#${3EuT}llDk;tDGl{@{zFniY7$?q|S!dxD&U%Lg0wZ@dOSPqTY|nlAQ7Pqc?y5 zPB%i?4+Q2H(O-Yk(j;uV)HB=3uGt8r-29fi84L)BM!;gGp>llt3-o&v?(cEAM`B0Z zexiC6FOK5nZ52z}RA3V{3DgiVodB)N6xex{L8_97LWO|WpSc)#4&mgH>E}_T;|3Rn zGyH*77%^QhxmDDLv@uK4rd|qBNSGI3kK<4E9$xt(^@q5rF01sb6_YC$TK;DYo~Vs0 z6SIf*PaG?3s}`CnAzocXI6PsXCC=XxH*%FXKrOGLCb>(sOlGyo6qA%C%Z5i43hG5tzYyza}ZinjF_Ph87e5M6*U=3FWx5Dr%;-LcwAcX#K7PS3ncWPty*ms2Mb z!>_0oJk5b6j4gTHw1SN5@pUJ( z#udE_;AWpMA0Zka4tx-{7qZT_h5q(c&UZyiqcjoe<`929QKKXlbeA~zY6g}ALQ zUy{_AonJ>0dZb|D1p)JE?%$t3`Z0~rq5I-3W%Md98@5zZ^G{{k2bk{2KGUl}sPRJv zYxR49lm(NL-m9sV2kpspZgkJPas^u~fBfjCGB?O>j4tmJBK7W1_KuD4Eet4G&M>W;UCUSvFZ+ysJcl2NhF z!jm_;tG{hX>60*c0_KG@w0uGU+US24*~zUXLt)a3js@b>?dWK40k2rlV%WfkFBJ}` z6pSyG?6kDTM;nGd_V6}Duhe;9E&V?}vD7|$yXHmW(U7Mli#MM>aK{oI78Sfz<6;iL zLQ*#T^#vBY(~;el(71}1TxVEA&(XRlX<_*KthXbI_n*S~zS!6VC@0+zwYCIdoK7m} zK_|}NnYCNq;V3n%Fw1xqbAmI>e(Vz25arg=EGgEQ(Q)H837y({lM8Oo1Nm zKuKZASbCBcc;X_Bl)uIi%r?M8P-`8AI)aAOG$XW3ECF=TOKY7uGWn^m2@b$W==+S&iqQ~DJ)(y-s=Nmc>8Ye$}6=ZX{S zMi3v_k`vWP$dX~1KSCqX70Uyp4XAqv(*(ukV(E=#AseguB@@?B&Zmymy}nla>G;D; ztZt~9PPnQs&inNVGFBEz*ds9tJ_0dd+`>mzfs{kv-0lw%$xwe3uqz8X;w&p&w+gep zuLO?j;+ht#$FJK$zO$GU_g@l}7Z&N#qRj`lZnOzBUQDI5{!|Rj)s&8Pf&qklvuiAV zgvja~gECCaIOQk46B}>*TSu(b=j4)BQ2}PE%4F-d; z#2Ax(aKA>M@BRHA_i_LJ_#MaZ_;qv~S6wk?&N=5i-|y$^`FuQ|=56QBOz+fe9>pg` z^ffOxf}r3M5zj9OK9;YAz|90~X-0+O+0FY%FuI?vK{4GruYGwC@eOZ6TR5gI5>TW5 zfQM&Vi_}Ptjh96$jybno*H%wDtenWr8kXC?; z?ZFnX?yS9qghI_Hj(UR<4EMdudYz)3%k^I$<&LoR+F)4;h^_$W1b`x(*511k7vDEB zhUS|$m~!nH3n@eU{3(ZeYkf7zQLFngq6lHww(ixAJ;cv*bFlUU8{Lt1Jml`V{T2h@ zPWzN{$FD-488tua|-t~TmPl7;s0>2a365}*B-9JXZ_dLO@Hl6@R$FuzErC| zUuH3ahsT6_npRXm+e-!wE~(&913+sLcAW&0yA^)mT|h}dRihO6*w*&!+O<{UIN*v; zaeww#%GV=PJU2naOavLuumL>oHV$6FRgb`EVzHTV@gl5i*ET6Q0k~?=VeFJ|44YHW z{aiUMEG^ZWZ{T_Oiu=^fAmt&`;M{NqPAd=|z)rB1A;kA-GFC!^B|afZniAk z{5tOkd6M;_nafEGz+`x0M0pN2fo=m(4h!JhfX{}9=MwjWs~7E?o$n0IP^(b90|JUP zyekjujK46R8D?^i%2)HJ@Kshj__zQWp8k_n|JyO-|Hk3QjQG1`xHeX-t*WoAuo;HC z&$q3vz){^xW{rKZ0J#y0vQkXpBITmnZK@JurlokXzJvNUmynp{h6*B*z%l_ zXj9=&S&bgZ9of;ueo-IgO!XW-DXb*7==j;HL!*YVw=k+f)3%2>UqnKr$^Z|zt$V-F zHQLO*JwXzX=w4C>>*VA9KgHGTO#6FD80@v=>4^`61a~3txA&9WLN}w(AFbrTAeOLd zEwew7{b(Ab^Qqz4L$u(qcgxNv=Lw&c-i3;*MaMfYea=}nI+ zdHEW2A;JB%esZxKlq?#_#5|U+Du?dCh-BO+K^ggSq}Koz+!WbXBIGpC3H;bKY?4yA zU3tlFnvII4EBGO{Tn5TX5pQdu*pbz%RjQCP*zcX>s5kTRM7cYd3i!&I9qo}G?faTw zrNm5^2&dme#Gfc;42(w_z>ak82#}RkGsXv14O|xVTe|00+Lip|l&nfWzhQWfO-IFX zDyu$sxz;0_7UkYRffw}7pW9zk=3a@?|SC9OB zPlPO;YYN?Tfrk_tQqs7|{EUJ@k%l+ALrt`-zS_GCQ@FwF zhrG$nxQgX!-OMXKH}bqF`~1S~4(sfG?^%>%x50~>fDyO)Ui;* zP&D6-u^kEX=rGPX*s44!(#AfveoUVh{ z22qa=)wl2@dK_0b<8U3G;hLB-`Myqp*AG=9p`!9Y1~HU{4rSoKbPHPIlPqfjcMh7i*)6M)0QMSIS zy#|6VrjJ4?LICG@NRV2SZnK>$zdgeZ*Pj%G0LP-Qb#BK5D5dwlSQnc~lN3|!{ynhi z9M~@!l=Zp4kLp;6Q%?!*oK|SMU0|Yp{K;_uya{R>OYFa*wPaPIV*) zd>?0^>2#u7*`LE1nme~Mp`}#z3wPe-B&>zD@2-1F4kI1>7DM!8h;B<-1IHgs9Et(* z&{T;2eJ6C@S8%v)e;l*y0v`Ol6RYu zT;$O+HAX<&XZTDtN;f^Y_e#{sS`KBKYRAYAgZ9iwkY#?QY>SceE8`s_iOhILEpiO1Yp+OtUXVoGADGhRW6t(C% zujWW&Ub#=&Il|~!HQtD6w|&eHSkcSIX~lJR?+L1fXWVQ^GM)VOraN+aA_%8CFGbCJ zEs|WHpN?D@bUMjRUYKj>s7>wN_VZs6l;+;^)09i^uI`EaY&zUa+@5eDbFk`Y6E_$y zG+{*nhJ!x(Fef~Se@zAXIc|Ag(DQ6 zcG|drko2iW%?7%~YvPt(*}k1{W?=DWQH^yUMNsC>)MCFy+wZ>VwoVa2(M^o=Tqr5WrqHaSs*BW?TSNvB*qhvVF-D$n{^{RjJO zWi5H_OUY}vjki3Jl&AZYu9QjOO5;c5pOf<@&&0_3$dt)V-zDHC>K~*XPW-M=kyU@n zY39FbdYrOP!15sDwn+tRL*P#$0#~k>vq!h);bXOyc%K%mgHBX=`hft?8oT)P;pYO= zUV*2=lQXRJ*fHZI>Y(W$wKMe{p`aJrNc!DbxJ)5Qf;S}#5!7x&`ti2b?>NgVnqD!Yws^y)skdN5V45f}V@goKxB zPCr%C+EIw4!cYc2o`3kJDot7Ut0b-USjY}3FURv`Xu;m-q-2QUQFK$S>O`s@q0 zyR|HKs+dmYBQJo`v7)Gx2JaM3Ct5nmew)c8gkEPc-Yqv8a$Cn{=!U>KAp@`7!Pka`9+gescwZH zKt0XF^h!ffkIHAxKbJd7G52bYs$u>f5>t;(%2vS;pE%de_MC*}Cs95I%R?IeVyRA_ z7ZtHSB5)76)bT2JaIxbyRd;};im;Yj9umZFI4_6U=+vjfRNHUUF(QYF`g!O)y!#{D z#k})BKagXQx=;Zu`rCx}+vIgIKBpI1u5uxX!U4`HhATAV!N7Qk)3kfAOHf=uv#s`>fw?u!-B{k%;$M4 zvRfsDJXmvcX3Lfi#Z<~_($CvP6_yUC@m3%&AH*=K+3nXoaQe8oaTrb1=V#AZ zCM%7p8qhnDD`o4r2m2qAUtH^l&1+g~=2yCDlIzNlpT+*re{E{IVKuy>5&tv0#cXg` zGn{U*a(^o+OzB$#k9-WuE>+^hIFnd9yIyH15FF?c|G?lzv`Qt5(Uc4igi9I=>A zI!BZ>2FB!-Pbvy9xMud=OIw<=kjW-Sd&B&zxWqU2Jun#&bUmpnO%kz=TsKWW+{O1x z>WKg^aL0OWM-r2PQb1YN3@*2&4D8tC&V!i_N%FBK;OrWOAB1Nl12;3UWE_7cytT|@ z#3^U|^UTNQhqxh+g(y}d<)u;`J&Z9S2jgu{hWi+p{@0Y%lH~k&m7&fDjLCYL<3dSI zO<^53Bwg!yBC^VzzSEy~yaT#x*!_Wlfp2$q;tfc>%m(7s!W5$#18B;Di8 zZ4wK~vj!d|ZjH$DvO2gSfZ&=6drJ^L(hhA|7=J(Llp^%Qf4>+aDcN<}_Xm5iD62MV z=VS2B3C*V0RZcue;ze=7UH;en9Q1_HdjX8vD)rP!5lCQzGbpFq&i`@(V^Lbn&`YF$ z{st~Kx5Sl&6Er?Bvosz@*KU`h%yAtfuw%_zBHg^TaEkd$JOHpQi6>Y2+hTR8%eO96 z{-9+Q`Dw)lfBG5A{4+g2g0&rCek`i7%fz{Vo)FcgEIZoC66T9=Y65R}Vd1=;bBJ_w z-SN|oqwziWkI6t}*(^T5WRTIKa(YvfE}EI;Ng{PV!H<^J`a#DCHzVu5XkF|kxJN#Q z@q?OH!!l*|^M7l-F>igshK8_-O>#WY>i1#LIa}L&4erSZ;sC5=9YFO1*9N<}tI$S` zh`)KKH>+}jo?CJThi;kmKiy<$Cr6n3Y${?BQ>m}>eI-`Oy4SSph=gA(;Qi@xSfob zgCFN1l^9BotyBP7=O~a*lai7eqnUUszMrR)!z$K#=3>>|Bd`&-lM6J;Z`(1%%QXV> z-LJx4oST68`Ev(wDc}bIsr-lmPvgHEy<$mC**h22$q>G^);) zidWn`^z37E$U<_#pzBc(PV(g_#+kJ--Se87$&j3JiUR~s*oLj8JUnQvE?7gwXjj!c!bx_{Zp{;1U#3~e$9a{m1gWc*FS>4 z*|mwMS5rS1p` zPpt&mUV#j6i*g#Yrs$gh(hLGDjeU!s-ah;N8fQI#uiM{5_Fc1izB9$>@*Em;4%!zk ztQjas`XLl zm0Z;6>(#8#Ga$c#aRZ8In>+zh3ZzwvGj?un>$-2^51GcsJh+^@gH}G-o2JOJ>wfaK z!o+2nABCmnW`CzS2+tT8CeLSJ8FjQ{r@Ev94y^Xjeh%A;+(T-bKdxJxOACDZTgIG&O{{Ut9(L1brJajiM56>RYv(p=~Eb)3^ zB|XC~nTXS@{h>uDk?5XAxc*RTJ_c}pjgwD~n`faWmS;o+%Ji4FUwf)Px~GzMW8~Bw ze{+uX)Jxoec{@HKOZ7Kfh8r8N%IjyJ0Z3u>VWsPP9uuk_h~e_r$QRc5^jifv2JHRX^>fA7EKl0*Ws5tA;mEY&!qUf^NQ7 z`-rREy}h)tNor@(sRWa%&2j|Zp1n*}1BYw(b&yR!r2Za*UaI|aLgL~PwC1v9`)&bq z+PzqZ8%IR-Cce3Rqld(N$%H{0Yui4V8SdC=GS#jx;2IojU)hDM z4SsB3N%ilY5LxZ@iNqh}ekT7sABft&MkHpIM~&`fZ9m{AY>1KyJbf0kaQgbN_G6HX zEv(q#7h-bHt(}>s&z6`}Ig(e#k%$vUFrROEu7y1pvfYrjAunbjQdEs2_v6g8(hv1$ zNeIn>O&~E^(;at-U-fz*NmH!&Hgec0mraGHDW>abmB5T zG>7Bp8^^#QqTT?@jHI)J*sSM@jf{i^?Osi%E99*^9D0}L=GyP~!MFf|nrFEvX&)c* zT1g~9PLTb0tDy~5QycsnOA`e}MP$z43Hb(et!o5xYN~LORn))HiORV^p34)8NjF4T zb^p3YmiPaMuhQmf@M@6WXR{iqXZgJX$q8vLGp{6U#nj#zV=Y_kwKbn_zhR(OC3WU- z3ODX%A*Fd{N3t?`j-y-i1gcf9KW&&JnUYT>axP+J@_(En28}3M?{}Sf@rHr78;ph9 zwsX$HMRj`x55ByOu9BPRPTA^a`wZ!tEXvau!T1&S$GTw=oa)n^iNl1eDDxuyd(~1Q zEm6l%B88KdPZ>K}XR6sBn3WCLPJOw?Q39=VpG;GaVGGGft@*9YV;^QBa(|<^ zJDB+D)^F+I!ndwyWW?QU9J1Qbosi+f)@SinrQbDYJvt;RJV^|(u4j`JCzN+ZaY1|c zm5%F2`!TQi$wvWU_QdhM5}w=2&?E1Q^1= z2r;xh|GO~Ze%0-r-3;UzsvqDVo}DCnylBLi^-8x^>}X0pzvcb8f8_-(ngSEEy!$57 z*DE`?ls6f(vbAJ%Jm}MSAA<164y&l+n48lx(M`@j5>B1w^h8y3L)*jsBp-{~a66s4 zN`Vm`p0z(Z7tfhuKRF}fo#-`05gk%4*lp~t{Zw|}oxx>Wn8^9fNSuAXVd--apLfZk zZ{P7zuk2}vseqrLUZ#QjTQwZAZtt=Wc|{GpR1gqFvObQ35(v14PQWw462~Ip?^5TI zwtNS3Jl1P${Nt!l7J~3iT(tE*ur`{qWX1F`?&mpuQFK=+AUtUfIXGnivK zO2-Y5b34p34RHkSS9VNT+p>`nuaAC;^YDVpy`)wvjPJv?+LiB-s2+>|KrDC0|E>ZM zBorRZ#_b$58O(Dpc{3ntAz$c$F7`nap(SNc&D&T2it6Z>{I$+~?3m@$*kkM$XJP;~?^Y*dnY-1_YLB?bYt}oy8m>mH zfSFZx|Mt~W5UKso&}!$Vj18GOPbGwdHTV4}+Ib!vu!t$w@2a4<)z|h!JHJ$`eUS=d z#l6}fsl8}vWFz4bzqCTZu@UqwZ*D?a2A0o}kxosH6 z{`gIaO;DzIwbj%zHwXgOEy^lFSGGuitrn`)JN}h~c?nD|>(yTFO*W1TakhFpbOfx# zn#sbH2o$JNDaTV5eRK8q5ZVS9VhOtUrfqi%B%`PFSR9!rcYHskMrcm5yvx2^M>jdf z9yo7xG{;`Ff4Q8fg&pH)>}TMPd`}g5AFzor^PWR{Uwil1{{g3f?H z@OD1^U~qo!z}Tc}V@7mTuF?-Jbp-F*`2rdB9sFL2U76Qv_KCJOvq{a3?{oOq7TJuW zGel{z+VDV67(PJ8+-r#nG(X5@V{3m!M>Fn3=yPF@a=>0YwGIQ8dzYh0N=K&dAuB?B zqt#?PgzZIwVF>H_D)cZnP`N%zHD_?g-9tfRk=PD5nEg+62ie}4?e*b_Mf@cl@R%Qi z@JK(WAo*4a^qXE=US2m&0<_?NJp$|wK<#+jI+*pLJP@GoVnw_Qx2B&#`2-J-RTw1f z0fjzi8JBJ|^2IR`Tnq`TYgWwOjb9nxMArcWu}4>RKK;cwgAP)jKaHAh5)4;#(coEU zZ@{A{=#=$_12xeAHhf{1Nz1;;%N8h_)|vr4jIY$+N$}UBuyNjXVk)(jW#R}4Kx&VM z6yhwoU!>oqlG_6Tj{=cJ{szKzG%2$oN@OZ<@FS9nQkI|;BJ3b0{7%<_rv1{vUt9p zo`DK8XmVVKyg-^Cs$%kZd2G1w?SIexQsbfwxZZ{h38K8CXT3zeqhfRyv+4d)HX7lf zDIXGT%rC?V7DE2 z(jC-PwS39Tm%&+FTx<(?IXed#tsE&B$b(BIqhgu3i$@yXon5@6Hv8S5D4Zqpi2j%+ zwG{iq20BNh#0ipYigiHuX7JxbO`X2pCCE% zn*{#9+8*GJa}mz?fVCB$rDc%ripaxoR>OI01ny3qKxC84kEIXPi09)kh!;~6-&{HU z_Br43z}*k}=>XDQS!8Drb5BHWMZouPb7x1RcRq%~44Bz>w2M~35tH>BT^I#d#FvN+ z-B9@Wf`$B#!5c2myaXGU6|1+qV?X&q3sIl?$(}QZ-~8kDrhe?b&* zzN`hvKu3e~`*aH-C!AqxoCDTpm&IRLG`zXFIj!356EXGVe2zd}6*An9mPgqT|MSv~ z&C`Xi7goF5k7&nzXdh;DN@>MiOG8^)#XaHD6$rlFPu<=3ApEU~WU&JMLB_Y@ob*V0 zVe9cN?3al6;KYn`MtcGM=KVkTgr?OypQR(_Gm{q<>&{4}9@^qO`^8jWJgEb;8B9tE z5s_jjR6HwhzX;3s4Tg4IjpnVm?DFG(u2tRvtDMfo43K48E1ue3=aSzHF2a1h$Pb(n zz+-D)fu27|7v>pPb>um3FN5j(2|t>At8xw|1IT(9Ey`zze)By(`=AHR{!~XB7>U90 z@Zj?&!A8q3$yXdYR&&U0X)&3~`j!zkV(d7%@a2S4j=L*na73s2d138cHp8h|FXFff za+&>UpwE0{vqw!NVP;N={`asnx}zw(%y?G-g_Ln}`TVoy#?~Oc&5#|m9pp-G+?3P~ z66Eo5{-?I?u`!)Io4LhAr2_KB4&BpAxwqmnh7^O-^Yr9pQ6n%5}>eRrG*ZNCuh9btr_qQrO4%p$*TIW1JHG!x7KjUGigtirfAO z4z&~;j4Ve@o{dZAA_qi*Wm@ZM&x}g}=u~%B1n=is+F2c`Q~sM;(T@#+29XIx@_6Xu zFH_N5vJ0`R_>IPBQ_+GaJ)R8(+?kxCmN;TaDHC5%T}mm+_#v+G*+%ag)WlQw#-KImvECUp|c zDY&5l`l9zU1wNN$w}vKZq?P>*dG-u2`xm5D{Kh?$zDK5|h&$xRCS;LpzZFwt5f0HY znKBK9ev0vrGBPz8I=q1TX!0s)XaMI2P}1vb_w=b6sxAIX;yK(6M?OrYkT z?z}XnvMEm(6O;RAWcJlqCEGH2NteR^CPZPeoc6uS*Gc$UxAhwnSw!NudpGZ6qXLo~ z^`nWn0qoJ)ga6s2kp5uu;8Vgzng>0Qfe1l(JTO7@bx_s-cUMUR3Ln*5)aQ;yUzC)t zwpP9U7$LG=?0~~U3bmrT!4z^pg_&}Ql?|d);q!#Fti;JOrY?9M4xIPW?ZBI}?4ZR_ zR={pn`VB)fOvRj=S+3g!ZIY84AddL|fH7E# z`zbkb@DAKM`<|yeLAMU0gK^-J_K^;zGcv1NS`y3zVBJ+U>??T{T9^1|dRy)f6qo12$cCWYJtsa$^N?0gEmMW`thrA1~;BD6aLO zeoYGrE;z|#`C^QTrio_-iO$wKt%WaAuHeP&&wL)h@}Op}?$y5J@-03L>JF7?+rLat*HwHX0K)GTD~2` z4#9nKGS4F5A{mh;o18gwtjT0pQ49v{K#qTs$k!0i_1YSZEp^^`cyo-^>!U>rbSf+| zMaW}cH}_7ZbP#H&<_V%s92-XS$70aZlFvH#_GLYCQ+ zA&YtaqN?qqe%#O7z8zA?CKgz?8+==yGBAWfIiM{I6QR z9S;Yp4wmWeMXqdY-N%4$5hjxr z$z}4}=1MIOf2>qrqr7ledKrl$*V^y=#!?s+QZFyJ5(&H<-x1;?K~*zDk=QrDcuhfA zk?tMU--xa`DILi+MWvqKGKrs2s6V#lpGo3{w2(%}*#xXd&uI+a&hR1-IT%?gKNxK2 zgYX(4k6R?i7@@nIb=kRgZ)B-sm69xl*S7d;X;BlUV6PGjxQF-k{jJB4VBFD-iqLP7 zFz-?EwwOx2hXlQHe>nM7u3WBj))63ma1$rRgN(y;fLfS3^{AYEWx7#55}7T%=Er-W z)YmAj-V)ud_8nrBuEW|`2`lsD^f>iANpewh2CZ^)C#A;z>$lb~6Dbd+r?4K^i99_U!SRXwy1U`j-te=OA%CXINw+&@qn($SPo&K+dADOq1jELQk@ zsHyl>!?C55n@#1l56uWyYg2#SB@HS#jjD?AqcdLS83yf3eREu_q7{WB-OCc|D9pcB zQ<5tI)7gX!!mYMMDFJao<$OCMtR|-EjB(G_Q8mUzR(SHy#TnkLiR)>%R&xI^obpau zatP)6_(nEqmw@CBRaz|;O`o|LZBW&^-<8ZV5GSHC@?`Mpt%7HQeKgaWJ<# zD`nmJJ^y?ZuI8Qa#ShbWN7bJC`n6bvnGFoIqu!A2MTR=n$gr|%O0T{)HzqPNop+W_ zvs<6K>k%%MgH?dn)TeP+G0B%EzXnXTwah*8mrD))VP5ybA{Wcc>Z7HnTpJ3;4N~+M zT;;Z0Av)F2BV-y5Y4w%N_yEmkX+gCDqc-iST(^dlT}nET=kA5|037dc>O0tQTZxMw zJ3YDSs_%4L(HG~lPLau1wG+l#LNnaW1?i^4q)_LFZl3<6#I_w%izQgf4QWE?KlXx` z({ZI^;oq9Fgo=qPo6*SVjonB6Gi*8}btsrCk$I{~O7FJF2;++rBy6@kQ2b9CkY<21um7follZ?}B8JK-wzM{NuMt#mW z+5as;-Z`=s%$IAM_JD0okY(^wRGw>GGSuY(10$E{vZE_S^+VC{x4LDWqA=Q%(I zj#&{Vd*nn(;?5NBa1JL;ra{O@93PKN*DcoJkQNX9*c%b)S=5zWm+l|8TWq8}(4kMB zj?ZY|stj8tH*NH~Hn^|sgmqn~@|OHZQ-Q(7lbP=v}1h8rs>?(}{um$)b;11?i_!$}+wzFc=ohIFrnBVyG_5`(^ zyD+{-*jFJ_sAuLJ3L8Iu21wPHH1tBweSe92mEI=F@{$ipY2K`eUZ@#x!Er1dUMx9X z#R;u}e<7^!)D4`?d6aekRaQ*PyNXYe`vxP;I}Ns(NJ~mJ>+4l_h(Bg#VOm1o?Qu0_ zJ@+?_w3)`%TA7j~xj2KSl9LIL^PJa=S(nS37s3nbQnsH;?n!q!EMk3$TE9QJNB;n? zsOCV#_@48mp82m}FTqfvXVM#1QCwfLCq-7$qk(;uw)C7a zPxQJbSP-7g{Q_4+8gm(TH|?wr#M|ahd}rHBxc}v%Kd2Mw$Z|<~}R8p=Q?7qRt@t08viY{bXCMj-Idcwx0 zf(uMd)+EZj1W)1%3w}p6H2T{M4`vpa(gRwxk$mT!8{l1`B7@uA0Rf*bR|}@GnO_ab zYX>e-p*pSQF67O_YiLEsyv-A}l5+3sC1tJFiia}q!5S$S>5rnN`L`rC?PTW6FJKshBboYH&0h@3}O1JfJR*{a) zPai_E-i$a8@8~au!kK>R8i*ltKEmaRVVX=75=+JVxQh7xrM4g8p6b>$6gB17-Suu>o6RQx{WAR+kjceA&XDYEE7Uu%>z{=7PW;ngk76nd z1s373%G-~FlhtNK1uV@uMT#0`HUu9fuEo89WT1kly=3e|CEmLA{Xqcf$L!V%Ff~!w zA_4wnH<5sJCYr4({5lxJS64+Um(X8T2@91=C)3HsKhd@!6=at^mx;I)lb^-scDAiT zdSG#mGE+bC7$N{vf>o*h>jg#qFjn{%S?i#sp$W|yWX1IrQ%Z(@e^6T!2^p6(u@jku z=|21!*>i65G0m#f6aG?`*tC!OiZ_FdRh>$C&-4Q0#68=l-#LoGMp_rw1@M`;9Fnwn}vD3U@yX=PX&rxwx8rFP17iB zJ}p-9kF^+>mA&|}dR(NQ#WKwC-uLl%ay*|)?zx@mdr4@TXvSaNt(jNL+MpKDmKcuj z+9bbm-QU-kMm?G|<+rTe@i0R_d1*{&b1}*H&!o-Q4j9gFV)YXUKur_2Md6r$Af~E~^HilS6l6 z%ISCmj?*g;&?vWAOZ~BQ*3z@xstKFwp|qkQ_}eN2uXg4MvDAGrH*%4X+8sDVn@J8^J7KGTxC zauHrpsw@r+7I1xED=FsUwTTcXRNT&hmjmrLk_>5}OAMsw@4US;){ci;pa|>rXhW>G%eLNhbL2=Cg zQcZ74=X-&FR3&LML!O4>n9}P2aR%m-bjV}xGEvZUTwVsa`D*TuFB~(`3VW_K8F!;R zI(^)xbLbTenI=M)$^%$)|295r6571N5_HAf_8MBy%kWA#W2$231YiOPp5BmH)C-d= zP??#{B6H0I)}W2rU8*O3G-C-WV2T+9ck3s;Obde4!tIpVjN1i9NiGM(YIO$-nr7}! z4B}wgUOZ9B&aH#?W_MS|`$)}Y*Nzv(q!;72fn;^EROuB28Hlz@2_7&jLJ(mc-i-ln zV7NY~Xkb88SW6yV8YNp0(@Ad3no0$;Gmgc#kXbM_Hv5EIO;JbkTLn{<=4Hygb))#C9?= zZ+YRxQl@E}pd@(+URI$#5wl5av&0jlh2_F|&h=7{4Zd=I4Lr3tyOxYoAb55k_q_+h zVu)J*OXVXi8e1BmCjrS4Y-INWcvY` ztDo^AfZsq4#2XMyks|z;$Vc_xo6n8D2z&0M38**Udzbc+;h$!G^?5UVg<*27njZ*m zLat{5u)qyfq$8OwUhs)mb0fWo^IAm>>$sIsC!O)f!s$v|9$$KUH07RVG8?gFWJm>q(`iS?o7D!(WEm$h>XrLN_p~-1j1Vr#b6&}sxSKT-0fN@g+kme) z4p-tFdAw*>vAz-ZPZl<@%`A_t>*g+Rs=qsc3vWN;<<5_aaif82; z@*&n9HmSE?Fc|5-{KnIgu;GQ3*KoS4AgRtX|J_x(v!9wbybY@_dWaTtWgy5{9g1Gw zQuWa&9FEwLfO_qY)oD(%l+AqFHZbH#B_Yx=|B$QBN0E-TemlTj+{R0Pd_-1C#%$NoA zfId1xZp~_fEc~LI@B$*HT3u4fkf8QkGPG_nT7xYo$`)g~PN-c#R(PoxBY*1ij;7sG zr`8nenF3iKF=toW`DSzcf=g9I06#JzT^C3}>UIEtRX3#694+^3OkDLul-*B>>9<4kWqvf~k4P`y%MZ5AK6I87iuZpi%|TFglba>qzESU0 zCtp|+y3n1htMptd>fLPXLA{x0x&BNOnvT+xrn*=^-@-G|CAv?SYwm6z5NbW1z7!9( z*}<3?&3%LF7&Z@v)EK?q61(d>P^rB%Yu{Hg`B8Zx3{hqPDndQEDJLR}3r2|0$=u(8 z&55X+_t4=??QDvKH3hqV@|UWahDF^0e?GC>N#bY91FZKGaDtPbQnf>J`?WG8>=&6= zr3lE~cN!#DyQe=UL^^Tu%(7Y$7nsprlyBUKPaomrZ-QD}$14)2yB-+T$qjT<_698< zs_*T%V01@Lnx!}zTQZ)x+nV-La*aPM^j`H0)D;N^4UAzSG6k#GC_}LqMvcqYG}3dg z1|v9ADuS9~w2|GI-5sbYQ0ZsQjP?p@tkhPyKPKxW`)ULcaRfQLHp2^Wf@FmkV2VLR3Lx1k{6Z z;}>P7GoOh*t$H8AFO%P1D$}HS{|uo!a!fstX5EkjhVl%!NjhD!g5>%-eu0J#1s2|o z=o9ow5qxC0!!+!!i_{<4oN@Pl(Mwf-5yv+>KWS-oW8-nxnYqTE_@DExe^*PhF7CZu2vXHW`w3LF%G*}$bmVP`22T07{ z+40v+lYs6iqjSg9Q&TUdQkhW0P*LHY?Xp~=$iwqlhKn-*3xyFFc>&(I26fu+fnC1b z9h6X#B=9@!gs-{~56}NtLKKXEW4d!w{khUGm=nWDo&(6e*n@xlBnn37x4{2`75#m` zwd-6q$+kdGo`)wN{WWQrA8Js7Og*fl-b8%Vy_XJ1s!I@%S;z($#J<0^C;j!nVpw-( zsQ&yd_v8@S1GpA^^&5C{_x>Lw04Uht8YOsm{(sqH@&BlZ|4D5L4= z>x1=m5iRBTVbD7(zTtbIzF<%*+-_-h!W@(6a&+SHc*(n;ykPX{IVXO@Xzv?`E~iX> z`-;aP)~Pf?MGsdoUE#H{REc1pt2yJ>?g5Q9_d{i^(H&p*)T^SI8U{iAs~KWls8y&i*;GaD>$+~5=K z@u9DFu{=B)>opY}{xD!N>hK2)P73jCK<)%$rkzed0<9;-bHY$mvYB`ECI(y^;3bFH z!g|~0ARNMn|K3Rcn;m$46#OZF4zr^<%-aRx7SUw?iD*$ERcJya;x3HWMj`Ex$3 z_&s-qok2%EtVCiLvcWxE^qeC?F8O}ZpT`cCP)`k8VjKjmd;ftxmH#<2;Z*uR`4auR z=3l)<4wkRma1u1lCD%VXKQ$w5_3jr*B#sfNYkdnlgA z0&gs0BmVOqR!3RIE8wfqBWdIB3DE2^v)ll{{d(@41j?$BC6MPRf^_13uq}!ZRw)Q)< zhR~lmqrHi%vf*@Y z&s_x@tPM)~t=*c-P<8@~u(X{i9xuj~;(vlac5x&TNXV0RpRP}%44_CQX95PIG_Z7V z=kf(zZF5d1bzVa*y76;Ad2HMKbvU&r4^nV^Ro4WqQL4~kHQ}$vqK|szR6y^KZ47i1 ze3`++YIMbv4ktRS7`a^z!6U~`K%NMS9TXQ>P=TmvD5u}rGVg~;@E*2-R+42TNRLGh z7C};cRZ2~s<=gG>NiJ-mT~VNagjY@EIg94NW&*sibFt^QOduO4Hqz!7rBx`{tTC6} z^2mx#K#v|5(~GDoK6L{Sd1xFp!J%u6=Ec%E0)V04qv5R?tA$QSaJ@!3j%7;pwG5 zLH8f;kLQaWh4t<^x~uvgUl=IddD*D`nHc65m)OaLa8nw&|}R(;+HQMt-f49G$YP3L|wpDU<6D9f#$lol-)M2txq$u#!~7l7$W}( zgaPr1(H}PP0C}T=#~_ z&S)u7F(1ek%!Jh4GAZt~zGfsV8R(VdhV~#&@N?B6lOniG-~jIRmYcx4^+|PQ5--MW zq-p&FwKx}nU_pFOpRbK+IJQtv~>ug_UD2vFYD)X94xa#z9QKFQ*JVmA}591aE!&gbH9= zCT1<(8m8Zb55geWI~|a~4Cwaudg0#cH4KeEV;cHz%PAzehz9n30O-I4+P4@5iyXLB zYYkiKL$ono(5r9x&wfrNhvi zJZCA45;fI`&i%d9C`Ush7!vDalI(KV7+7T=v4MButZy}cl~?aK>l`!R**Id>SgATP zZE$M4pe9i>xk;EhHZ?b0whduUAHFqn{6A?|NuLK$xZ>(Kb9~ad$l2A=)5?;d{NAo7 zHnqgw(E8NaG$;F`7PxZ8{Mg;Q_pk>UhNctw80*^b5X_L4`~$-=jHmMq{lHz*<@40m!spp z{@J;+sM!cBXrgtrf|so-*E-{OgtX_#0ko8{F?JjqxP^i!HL%zhP;9)VxoZpC0H0iI zeOfgJ*ZJ(jDOZOZ2sK?vQRnAAuZL4~12s*_`F=gcg};44ocJ{`5$S-;l1mw_>_wbC zE%@guVM8G1a`y--WnYMt&qR2Y(+;dYiFEuTK?+(DuSDP_!&r1tKFG?iIqX;~SXF09%nL(I`Qb5cF4Ei;4}We$$Chg&duokR@O9 zQ5!7Mk&EUV@LEGVP`rXg^;H|oX5xB^^d{F-Kicfmspm8abc4iq`Hx36S54Cr-(T`e z=A$UPXUQjc57|vRA2Gu`msi-zQCkN4)a~DCUK`hB;9f$%S;H1%^M~pDz2Z@kbmDdP zvYKLFh{Ku$IugTcip;-IG?oHt7?H1y(pVxPdp^m!WqbzU+&@e#=IL9-z)M!bM*={x z4hZvtd%zrcd?E_7>YBW|a3r<@y||B3-8DqRy3J|gJ1Xl?z7-|uTf``~zv=hMsWQgg z03C!2md!WjDcWtTtw}8%LsK|^FUUqw zmnYQcklA|QyrIt1PLxx-#pRR2Y{H)NXNZ#(d(zuv0+5^~MCuH52T-b#6Na5uVHMI# z`AedV$x>oiEO#9sWL7kuH4Y;!h!V_Oe%uLG`SnNRO<}E|I2*w*-vPY4+!y9GuV92d zm!_TtU9sFh;=TD6b^vb*)a*n?WO){>me^c8gP!}NoNXk&<28qE_kFQY7tA(#7U?7d z1>(B+S>H1qj)N*@X=RDwB*u5{9mJFkQp4zIG)?5m=0GW)!5mqA$y#30tZDfQR;n0n zlyB9%W(|qab1BXtZydjyxX=JexXmmG(MFtN!oAdYm@21)#%8;=zb3%_4|mSNAYNSt zxLDc%7Pp}6)|61Z)+DyOBc%-f)MZF48r-%)#$r*spmTwCJG?>Do0@{XR)zmC=L<7* zzf1)Q#Zs|S^;)nYo8B>qXKRQ=+YScHJzKrjj51v>tuW8#=1)$_!M&O7vxcCZW&e-g zf-<<3MB%?O(IdF|<;2sW6$VAQ#2U+68?MARBwC4ONh>EXaZ?kXCu z0CPp)sX{lob99L;sYQPfm+NxAM)|_x3x}TL?u_+>6R3tlrz2W9V4V)$4AB^KR36Fa zqFl#d&o7X%XMb-qK+L@9@5V|ge3S}kVT6bA#V8tm0jPI>&#ni-Fsh?I30C4Ouk4Yr zS^7`L8lKQVt$Q0qFov&eHEI@YPy&}bj6v8_emH`;xS`%}RimoS$K54K92_JS80+(f zxNBTcpB5b!|3e7ansIC=Je4yc#{X_h&VOgGqx{ZICqsWBF15>--$qANW(;1$+hZ%y zQHK={?1DulYu8S;nBeXXxVE{Vl|@L0`_WFul);vM@*$k?5o$lFr^lm*-G1V=GU3#= zSRt9OS@>G>KJnjZRxDo4t<=2G7EckQ;VvzJaA2yt01=A!rxv~T{%+M#$n2jx0DQVfHZv=_ zEPpDzHNq_e9#eAs#JbLg7V9}Rakv4owCb8cZ(-Q)kYvf$e^ShyZW>uPup-iviAoV^ z@*UIPURw@ME(LVO*6bR1d1o!9M+>J1X5o^6tTvSvFX}%LcHh@D?pT)$EyqXFf8V0tbB2PKa~t2kM9vkn?6S_xT1rfnBWvwwn9XGX#86Oo(OP&T8+Mb zk(!1vYAt1T#xJunVqD7%*W{7hy!J6sJg3<8h;MhZDU`eZt8-6LZWo1mn7O#<-R{5dv@$pmzvxfcW&&*w;P!ml?Z38nY z`?PDk2GhD>gc>cS*3_>VSeil;x#qv9xgd|&Eyk`71{hue!+ZOh#IkVRpKQ9YM|y?J zTK}vuuX^N#R>6RyTRAwU?1BUDRK+-kyLT;qQ`{(jLlWyEJm4>ORUbhjHV99q-?gqW zy#pY&!AyHLJ3lm@*}hc9_W5AR9=Nb_I>Uf*Z}#4+ii;bg%dRDs9UGkmSvh~2Vf~AG z<|}U8I;}b~`iWUp|7dH9mfhMF>n`-jQ`N|tfU4OliEUfhKiA}&@Df0^GIzAVV6Q-& zySG*|l4{fNPN8j(y>6wzvo@`3a?-_qR>=YHwv{|zda$^7ya-HZ!eqw%gx!>Z&w(}W z@7cR!=OBH2@Bq#CwkeUtSmFIybfT!4GhkM=3bGXId3D^!QLOEa(6xe<#JEx)WgavZ zNjcG!+sv?>^=%!(oU)6I;tG6&qTN%ykqI5U*8IFb6>5DJd)a8tOLK5M7qQ4a+d$%s zy3}xvZO>uT6yTLnXIe`JqtXui3-`oS+_uO_-sjxN`XZ8a|FQG)zDsFa1Z*krd_HHA zw(DwDm3RNa(dDcb#^cB0;^OR%6gcO{;F2ow`q2ct(9V!~tFE*e<4wY4$%a(#5Tc!$>#QOT0`0UGpT?o7_ zm#!q?jhRzo+LU)9&u;&rldjPQU{A1^(B@VOEfOv@jE`AaaATsLv67FEsC;#bp7Vh# zRNBL8VP)kCq)z2S0hgyMB-Np_Gl^PFB1L+B{IX5rHCeQ@hDpZ@lk zrQ`RRPYU=R>tkx7yUne_vLD*{`sLLw-V&`bd6P}>Ua{VnbjrrZb*__4PQuubq$~#5 z4)_w@FJwx2l(E;U#3 z*`yO`DNB8)Kh1efs;9oc0*M4RIIV8zUU>YU?5zmR+pwrC-oXOEz6e|7z`uCIOv1Jg zbIGs?z<$sEai^Vi{yy{>Ry8`N{3cIEHZk`e%y0o%yed3;!BK?o;7W}$i2T;{YpJ+lUXB^Cg!(vyf5_{U9}Xr7v>acB%`pH2id;g=@d zPhQku8f5p=GIID?Bs630*GmPQWz3N4R5l_@gK$;pLOC>BuGk$5{GM;oN=}%$@Uqlk z_!=^84C(;e2yzn4l?LL>pvT{@@cbMchKqUX-ou=Au&qjgO1?%=rNO8LwU+H1N<3Q~ zGTmVRaiMs)A;X?Ju^M;~fSRByzl1A&A>rJfSyb2T$0;?D)wV(}$r99*q6NfrkhBs7 z@@`!G8N-)O$Lqw|A>eb+hhwj~k#+ji*pxYVPcp%AUP3X$xq(W`Ja*7$(DxNUriG@m zB1J;q%#}%p*ywe<$%vcl!x*%mn_};~^iR?Iu=<#1&=eQE>c}J(jIeyyb>JNbU?uvg-Ld@j6dli zDcF9hVG>_H4Q6`5r#kQo9jzBXw#!Zm%@PZG!ej#tsO?}G@beaX+Tds|qW*qnFpUrk z)_ZEsR*t{9SFekyGjvs&3Ok#LZs49Csieo!$_v|Fio7vx?_GpomtfEQXX3y?nsQ1P z$yH-SJzy@eisN%oS604Udn|vPpL+N8~e&seraaKzTu7I)Uo#vSOfhc`cKy8%c(|8$_ zSeMU>M(={-UfH{j z-M@>L@mdr9g&+elHoOHv{OX$gXx9zAMXY+|SNGkMbK7XET9ClSoX`_5nhd}K5BNs9 zOp2r-22XLv>*`cL)u={7 zqdnw$o%fAO-O!8XRsS6e>k?RPyvl%T9tilt-41-TkU0iUZQGgkzr-f~;A(t)25*at z_#cwd=zuqNI<4u+ngR1eb|xB*!1v zcHdMe%k#xMW9xg(jIGwGw9GZpZ6C9&n}Pto*my4FxY}iYUJ^1Ozwqq-xixPGYp9n7 zCMRK+RiSmMn7)eI;MwOP-Z6zor~|kATMjFDn>UfcvO(YR_ZALP4p6|wUxw`Zx7^-5 z3AX}rQl5EQ*i|uOo^!B?al_jRZ8*Z*d4{X0x%KRE=;*HQdwc9FOoIKszwYaDE6oYW z2ieFd(BBecom9D+Vb<`@9yhasw~_!>cVnSdwMl88T%@j5i?mJHMt7wQvyAbJVN}d)EEioaPVwIls)1c1rxBb67yg~giM}V-4vMm0@6M>QI_RJhKdN^g zb`YB}d;c`Vw@b1wU_b3w@(-aB{?SJ@_)-JFY}duZW&>V7WHckNCONP<2LTKenJ*)z z90DLoz)u;bZYkJbkM=-P7K}i!Px_oY4S!qFN$7Q5sV7uhP|o{^(uz517vTIdBaXV9 z^t>7A$v$>n;7Er5zz0;i|Essb@BgQ&@vo`~|MUM7HT!>Ml)>%E{`T0zzdQk9nO!w@4@!zp!X2%~vc<|XhZZUbVxUqNVKqL4GgB>ttlXfP9 z23l+_KMsp}5VxVRuHXiU0k047z}*?7P+HQKS~iyt+}tY+DmU8^DSvNEZ81aZt7rKL zD2;eo&{02vpU=UGCUU0V%I>_1<<{74Q!a_5QN2~tjfwt%Lb1UOQ1pIP1q@G-P)YV}leindd%y_p4>f`7+3Q^G_kx0q!444!w210eGzW)$o!I=|vV47u zxRf|a>_e(*{l)fu74EDc zwbh_3$^Jjm#PaF4YAA9{9f-B-?ENEjR4|Rsi#EU^H{L~&s5Q^bCYaT1sh#JMWEnS7 zI5@O+LDek_A|liDx5xbWvdLWP9-?yGGeE@RuX#Ru@Zp{_X)P`_<@sc_Lk}6VmH0nZ zee-K;2B|ct_8WoaqD>DJUo3UN;*r+Q&tcmNZOVLKAMF9}f%7Cs?HPvF<3B-q>s3CE z!P9^k+p0E+L8hJ4Cv&9u%|zKsB{bSM!CV7KBCzTJPdZy&u1%ap?A^9yMwH`*rzmZ{ zSWhT4HpA&1A9yZcDFzmFsOhV=0g=UbD;I|}9GDlg%?E3)WdQ|+#!)>%nl7^j<0)Cc05l@8 znN{yo+Y&{`i3>~qxz7ggsN)Eb!gJ(S9%*K5VX>o_Ek5-F$4Nnd_#PY_)3fk%XRA|O zy>>#v#t2U=*1U333@VR9U6bhE1ATqJxaLumgEY!EWFC7&TW`;VW(W999;F}bnj5S1 znsy3IS*NNhH>nLk)(DD9hW(zbo2!krVzND6H@alwTUAIc=k{wQy2DehUo}m# z8I!B!bcnVnB*zCT`6$7;zgr~_*E11A881s?v2cc2CtH{my%~msy~Fs+0Uw^S$)Ye1 z%Cv{}6!z{k72Uk|xBMzJ#o1OgZgtNDk96{;T1iLB`Nu_{4MkAmz(pLOP>hF)^=gQP zb3jIbJ;d=7tB0h)9eyB*S=fr?GFxto&5w+8IJbChyLA0FRl;Ao0-<*7&^{AE3J1es z$@J~Q+4W^oiwY7}#KaWnc)m)-*`U{tg&S=i2(>%@@b1H?Fgdme4#4`!`b89r;R?+e zs+T%$`HR~_It7CFy=72804WvVrQ-#!;QiYHwbHKT>z63s5#U{*i4_5#$D--U7#LE) zgf#N>e1g5wkJ6%^+-KATcegL|Hv$^JI%b}Oa>qT}-q93D)lPx&pA=8PW_c>JVwUyW z>9&B)H>o_a1}9N6T<|69Usb_bwvYgwQ9sn`z9;?y{Hl)~%LRnR2)u+uLXSC;AOC$H37bai5TN! zrNVS7*pA;NzdBJx_1%4eiN@5vZdVa^jXHZrcx)m(TZ>bud`x{R{jKZxoWI(kX0Zm- z-Pa8p|5h6;^HiFiV%lFc{l$t!9^va)3c6FWQsT%IWR4gkQd=NH<*$klZrS-0A z!~=#2A&4pq9X3`si>mB*Gq!`aIv6lSR2_=Xe|p$-U&jG)1@Jv|4vF14XzV#OG#gM; zph@9w(75ag7~OiB{KF3~_wOHI+(VTT6?*m#HBcSS%U3XkuVAdnr#?)&#)*k(!(;?p z^i7Y)0hN}Jv8CqcF~OKKgWwr%3K9x+u*~9TA{5>oyDF}4-Zi&?;XFlF1sRSQ2r>LM zyHh`f{jAI9otm5jB+ENPZPeA z5xJfLD2;ye1=VIotKTy4X-ZbAPvS3{>BkV&|3O=!Dwy+~9rg%!WP4?vZR-K$_FdIP zheJGDM^~g(M6T+Hq59+Zu#2w#3nbuE*-@{=!E3MFyTu+Il~X#j|6QH9Jx$Ee_VnEm zK7LBtPWt?}Q5a_y?FFMa9wZXi%*pQFQ~^p&MAAoPT$nH+85!4iYHuu7w)KD-{V*PJEsfitPn+?Lu`vO7dGL- zaH=3_9xz$zaGr~6aK?;QpX7Ty=IT_-d?GL2RCof+sBn^(4-d8ReO>vC%f?k9$Hu;tyP)O&CQ!|&~qEGB176imEr;t!Z5#zbOX=l7dv-g2{Y zhpbJEzPSYj&WMH4w=ROn@z;|}vw_k1MnkLdPxWdZxaZ`UOWANezHG(S+Z!T@DV|B- zxF^2Sy8Fhro`>!gjR`7c_AuG zb?@`$tR?wUb0!~}fkHawIzDVN?36e8Fnjj?d-NsuDKEP4`ww%#%9rg3F3v%KbvrYk z2>|Vq9NxLPOI(~^WNmc4G078sU*B5Kt?9n{$WO;>9Ef^(oodR{y6oNfT=9iNGE@D! zd<8yNJ%8r>js4ng<|J7wu2ag(Wor)=bL6)siW?&(zNO1)&wrUTYQL1AucOw+cWZT+ z@pq_0N|>>{H2MOJ5vMDL(`ZU4aj2<7+O_i~ATh;yqF3Vc-)*^x?|&yY_6QqS$#t&k zBam_MFY7ld)cNtKjtx~3-$CW@=MpF|}32ixvY5L%ek-;e|FE~{uG@FVTJ&+aiX z?e`dS4nCUN8PFVoeecROmhDyg%_V)9g-Vg@V7Sgbl+_;N(g~QwyO43$Qyi_hU(&WD zv5rM~eA388;r`yQoMhjdu_Y+{+x#tiTyE2YLyEG3YstOCm1ge=(~oM1_ivgbV#AAx z<;P5jqNO>hL(89j4?mvou_J1xX3I+(1_~WEY;Tv7QO3yBbQ7M&lUPa>6Es;Q%e7J7 zxFMg^@^ecCx{ry-%h_bI!(M&>Y7Se4XZK;Cxu!=~FxfDLC6E>wtF zI@gS|2i+uJz(TES>L6-XN$xMZ9zSNsgD7_+sNJ$PJ$MyMp4Q7r6Dqz9pQsMNKHTW^ z5DkMc=Q1hC5^&Ht5ZsKgk1*8*;RVuAyPuoy({PQ=yc7wEhgV=#)OoCA+Bi(bTRl4zc~(_bC2%8vE9m`Q1M+aCg4W-s zrfcuve?r%~PSv}OAKRDF=5;fi^j^^m8edrmfSV zxO;rt>~wGAHWghh-|p4lRsx8qZl5zpk-d!jcSR&0E8DKyfNNH-Xb>8N?Ukzd_a7bd9Xdb?t2j3O59aJHGnHn@_DmKd{$py-)vg`0PxqA zjZ<73r_Cnr{#!7F^{^A<7@1PSGBMSxZZASo^0Xh4Et3U z>oq%I{-01ddqj4O@RP#!H#nU^5CV0@_&5Y=fsX4j+w%)~I`FzoVL?g?>g>>^1qx<0 zXILO1!QlDlo++wz%x8w;v+`$WhgzQR$Tef#K80e~$PQmFCGAXmZ}?IW$@+}2mq*8} z&3OrxL3Wkc6~Pe7_~iykq!Z9%+LT#ri2d=cGJN zHT1p0rj37>lm{ZO!Mb<$NtSENLYtkkMZ-RRf2%+tC})EhSUP1+%(wqml9n_}$#wXC zT8JTwb@3U>A*56XJstMB&cVwl-9b$Cj@GF>74#==j(D*1I)0M)LB*CXg)e=nOqe2E zDXk%lC7Xve_+_TnFjeN=4pUMoYFj@H8w<}@+g}c^yo{%$0WYFH#alkoerMftm=8da zGo4x%mA*$C_VSkenKh=u;OodgD0Uo zDG`dB{6!gHsFAhVxGNSUmD)ZXW$g2gEP*$SpOV^Y(C=LG_KabK^t11U&wx+}UJqqb zF^A&*KlO$-)Wg-`e})=|c#ef1Mjs3&Y+v6JJjuR780S0}At*I*i|Gmuq z`JGqBdO#&B;%f_gPxv9GnA!;nm#N!|JZ-MWPCPmj-IhTZ^~_qc20|Q=_x|^q`qqNL zwMgR*)RBLW!8dJ^J4b>dbkaYQ34hz`ui3HmGpD8!s)6zi$s54|_nwp{Gfu@sZxzEQUO=U+i?iaS{^YAL`rxrKF-{wn7V*JdP3tU$l7^`9>k87e^* z@GiLp)3NQTw$*93R`6WlwsuMYvOJ@rj*DMLA*id?Llo#B-tjL)sO_!@8{ev)e|q9F zOMVd3M%(OuJK4{5mo+~3yd6%{filSO!_*BTg#Lm1aw^uv`wK$XY&&R$-|9oN^{bzFHa`JEwDX<&REIF;u|o6PWij~ z-mLAY1abHYkDN?RtD)vZadhnj{}}b0eao!Tvbx3ybY z(~Gh}pl%!uP)Xtg#7)i3Pwn5$($Fgv-U&EAx{n4GofFFY7TgVtvNlsqkL~Ss?IrHo zq48qCuM>RFakB#I=uZzw4ge{#IT4*1M^^wW^QAs;I=uT#*&cN3e&p$aUP!3SQyci% zGeSTzn5vH>tF8$(6kx(qXh*J2EnRgT4h!=Dp@2JD9sWGC{35il;qjpDz|O}w*riBlGC+v*>BO`V&5Z7ul0n0BSN9qNc@h!&8(X5 z!PlwlLa77zG!8|NwRCSGiL|dd74wOTcc<1FJEC2s@4Rqbw9^<8&~A{&AJ!~P_?(L{ ziu)wv)cT0PFK!D%0H;@T9kHPgUr3b7rSv|uyFm_{zU_|SRi@d*{6uj3DON@6U^`lF+-B|ix2`QpdlAWzz5@M$bA}^B*%E5Jgqs1e+=wSh0i+xz# z$76x%B$`n}<<>P#yBK$C;}mfE;bF20!&dJBJ%#WG?2-zs`7$Zn40{((=^Dt2Z5U3& z!P+wBNf6Yr2D^ywH@2Q?pI== zEHCz(Mx*I&yu=Pveyu3W!AI&X%)ih}{%USJ==ThDDQ8ZV!$4G`ppL}?PmuX>Bvxny z*`~!3Gfc|qjQ*kJ%feu{vTzf{{T^UkfLRhGavh+p6#9bB=BqZbP09UHcVP7ikvEpf zqZbs(=TCo#olo+fcpGjDdu52^9pWi=YGekpE99VVuMun%>Ps*P4z=s%(Ky%_AVF>B zsHw})C?&xL0)*gDHMmYvfieJ*T9b?rJ(iiV-B;lzs`KF!NvmsB{|>(Be>4Sij57MSYoOU7?W=yQ8`~>MOmo>?2-q5iz>tFAD8nTCG{f_b){2Woqh0+jz{V8YKcX z^3Z$dyI}tKawYH?X+a-C9u}cCvN;v*K9IFc?s? zqXXI%UzwABvCGM@fmkf?>vwzuTNQMv)udM@^+>Q@^ciVNh0mWsdG#FlD&9eOHb$@H z1@1C3er(&ho0wevT@PNHm%p_ifR>joqB;~-)2%YvpO=Y8cNS`m5aQUkf6;CCF+k!M z2aweUv*)X7!Hy$)Tb)bJZw~I{JI?v18a-r!kAsB(GUMZHj}iJ@uL++)#X!+2+iVPm zLDPN5sc8b|lZ8w4cjE&n+9-pcn5sYX{Nr%oA{G=utI+!1)5 zm)U4HZfZtvn;nG|7sL%W)>qAwx@n;3k5uH(y=`!OO!b|!8@b&F3DRMfMV3~`&hjYO z>7)b-oc{@%0Vm}oRMlz3_AiXeo(|PF_NhV|M(|L0xmORY0D5bY$$7+^s))5{1 znx}zN3|6?R!D|YDKG{uMXnG59C0L| z@H7J_+a!pzKy=B(UTR}VO2b*rwt!BiPBNJ>Q1h#KQ-|jq5#0`_y36Fme>5n#YEF?# z#+l5bMaWs!xCBk!ZMsAzvdQQ5tG~c-y#kwCc!KFS6rNatn&{WwzR6|zWZ%aT2$FM8 z5798rp})nX6$|)4_Sg&l-}@aT~qE!2_$CB8r^`6 zC^nn!RKuWo%^Y+ZsHdJpIU7J?@|96$X3fc_tEAmcQjz0j1Q^vc5^IZjeJiOIZfJ=* zMX3f4qab}mXF5s!O^#e@STP~qC(HK**lJ+Z1g!t{X6~BAk)q}baQR42n^{R@G@ceu z0;RVs{4aSxna5uAwuBD(b=y}N>7ey&(w;h4!2Y;|E_#?}YhJN+*U9!Xqe<8abzM}P zLkHyIwpq)}0M)$(k*4&>2h5hy9;W{K1wBT^Qrdcqu(P19jMF4xs=T zjo9tB^7~~v?0)IY=;5O!MS`RPdXh z?*lh#99>FoBTnlI)81$P1u|2Lk$=>W_cP#w@Af%HvQ=Mw-7YO)>O>}XE+HA})}!D? z49bBw0Q@#xrL4EtxF7Bje8Qii^|yFexl{j@Xempj7BXW0!0rk80E*nV!plbODfo~893Y8bhk_`yd z_#V@7oK2qQNP*KSo9(J$xTfuStoIjpZ;g=}NAF|#T;MW*CnuLnz*F|DJ~#-OL$p8h zamh6EqyAZ=dmj$O*vG7>@9H>}VpO8RpBm;lCmd==^{+h80@X||zz{U$3V7OF`%m_? z5GLVwjp)UM-9pW0&q$wal8uoXvd#RZtU8#6JsE#FiodVo0JsYw*lF%EB;b^ylR2XU z=oq)lB283Fo{#2P2iv6E3%;*J>J3wD#kt=F$XpA}Fn>u~ey#rV?5ulCMpmm*E2Av5 zemF1T*U9o(y^H(ZxiRsiMNrv1)l-y-lUCl1!KE7N`sS<6T^;Pd7;x~fmJ5lCQMXNM za3_4k1pgc&E9tR}>984(dl}RB&Wefit5cD|`aj*vUkgf9sYPDPaB}WTXha)cG(B@n ztlazTXJoehnJ*%ZBrXNR_IhP$eM;@SaIA^+Q#aa2=1)xjDZS|WrSTKe0u;|$Jq{9Y zgh2ng=&Q|hdxZFfn(h4BA{5T^aF23S_**7=rbIi;}eQGy+&JS=ivf+cg)$4 zkfRjGQX07Rpt&o4T-Vf0#3aMX(hZ!%D-|&U18U#UaFszJ_{$_&u{Kd)^duW)me7Ys zrQ4}u$<`}##?ajnfhM<2h6|)T;nUVl`B)~qIZA|yi;6ajvA`!33MI^*aSks*wl#Vq%xNrL8;i|WRivs9@%XjxdhmB*s@?|kiMy2_?U$ywt@^U* zOB(LT4`M|;(I6V`7bynOtFJPg;G}zrdwtE_*T$?VS2zZt>uY)Yo_VB^*x-0pXV3fw-R+orU68-KZw&-6=J_Q|-(CI`q55cfi_ye)*xE+Kt7RQYGH#IB-rSsfq6rF&AfPt+9w*-T zr7*X->X_B=#Y|o^+}oH`XN|Tp^~R_Ig-x8J!jiF!7?S%$X${waoq*}`++3T89K!g6 z%wkhq@#Q1Rr*cLjd61EC;^hJR^p_bdnDamq9cV@ec{snmWXK49y~9S2IOq z5XP77_q1^TI?v31P?#PvC5H`s7BE`B)FP#Q_zV5%DAt;@of~J`qa~)#5Is+>?AyLLF zD|}D7?&quLUG0zalODsr)eB{~s)q_cXee+?vX-sig_6Pe;xHY8sqO(DUz%ditZ?gf z7<*4!W!=@dZ0{+b)qA`=q5g2KN&iVfA=9%w6@1m7Mc$A;{^fqrp#9oeJc@6RcD-cE zUJ5NADl7&{E4&XkVnzS`=uknUKa)i4=6=KE7zTe;omqg0;N z@btTxi|GjQ80=cl%SbPou~DTB&6d-VdDb3X;k(7{D;i`I-+v>&CMic#m2@>_#ta!Z zBqNAn?Rl4~)@OtpW|a%rr_(EOZaQ^!f>+za(@o>3VEc`2LfMt0TaU!wy^K5ZtK-A$ z{EF~bWyFvCb8^N-F8#_?MPtshzVArvG6F}cQ!&N{VL`a7(ddp}C-xG}+n zQZb94c;!2LrgAvJ__6`_Wrrrcr*9f?hWOfE6}!PvS4+xYi?iq2=M9=3@b`X@A>ynQ z-_K;VxmLcrxL*b}9rc;oQ!HvQy8~%!iQuoCc(qfTn19NAw8TlmT%4L)@!{6P&8jQ~ ziQZWs{md#t1Pw>IO%+9?y7kM?v0QVK0BCQ4v6@JD&0O@oY&q(2%PN8ba%WxS=l1)i}uVstwmzP zG>1Llp#ZOcqjP~?1Leu8GuZ2MkkD zT4bbz;jP3FwiRul$>acfz-HPV1vmFaEUevX!q~Hrny{&~Jr7*Tza^q}E70;iuPNnG z5ytr`sSXwq_G|F(L3j!_sD&qT0_w?!#o`>!F2*#(LO3Y564tOmIkV~`6>igXmtov5 zNA;AsDD}k8mrL^A+v8`6mm5HrJc+n-*);S*@Aj*o73nH9TU(&9GotBWbHgMFO^)zw zEx2*Qnen{|drV79Qb#+t4(y5>iNw8KH(#~jUHdwW7@lIiMto>54*4wt^Kw`D)a9RG z$1X_6y-zrFK&O>8L-@o!1&k$E<;w02KrH;)_#J{2sHz)ExtJs=#vPTH-#h_Y$i@aB zQ{cT5(JI3uu*N4g@4;{SUdnah4T;o(O;ynC+n00iNjub24Z>!Ws{&_zzHU?S*ZKe) zA&_tTv2Twz71l2?e8!`FEi(ewSH5UZ+>WN$vOUu%q zk)V_k4jWrT7!US{86cp76C7HZJ#OK_JC~Dbk66 zdw}J=?Z33Rs0-Ch&@IPyt0FVq?>BtJc2J4|c4i42SHN z{A>}TJHW%BZ(bo!U&pyZ=@!t>$wU_cpO^p|A5JI~uvV`h@sBKHgSH2j#KspL*9ul! z5>hA+x}Y)S1yCSJcCZbY@G{svopBDW-s(qNn9$g^v+n0H`Jk<-60}%N3Q<9RdE~L} z!Te#YCU5yQC%_y7R^!WKIN5~8$#g<}(-b?gx0F8X4c@)t(u!RQlLaG;TLpKEGrbX= z;K&7nHZ3ja?x{rJD~tYkxDMM!0FIBpr)qDmc{$VnFxvqDQ$OPQ%=ZMep;s<0nNwFY z!v*gSPlq|LeV9x}Z)v{2V@1Wo?TF{hV%Kf<*0)XQp*eVWU;U%4rPT%#n9k`3Rh(7t zT>_U(;^k@j64dl1`m0Zh2-H!qZbEX{{wBw??^N<>)!%5&O}9u&MYm6qKez$p$|HXx zKLXGPX7_b{H%}r#R9TTs=7!I@#G`|=*4U&Iz0k%1jUxi@1 z#<+KeHSni{?+VbCjLvw#)+lhe#9Dqi$o7Y;@{vm(@d@FoT8~?q2$%>iCA>~CB5&cK z2uMS**Cnz0lYm0qJBl+h-ItONRF~0M&QYSCx&-1Yz2@_&k?Y+q8l}n0i-=7F2Bod^ zWz24pd)M6BTCV5^%Q&6<$*X&|LCgmr&tPr_=j^nPe>jpQA+AA-m553rF1`-eR()(- zugB=*Ns&3`XrUYP?R|lUrGGvQfoFX5sPw_jT|WUSFB?!yPnZ$b%*K{J}10me|QZxxA@%GI^T8r+<%Qp=oL zzGwv>tFAA(q{@aBt?+ zB8AaQG=z7aDWHnNMgmUtZj}6SjSX@3Q2kn(6`Jn4czeaa)_y3NyV-`b#dsh;xc6ui zk|wVf>;m^Rqa9z`k3uCqi%2u7B0T^XG$cQrUMX`y)K!lA6oS9TiugR-WEC z6jJa1nYxyI-(7H^CWY+Qe|pl_F|tbb2Sc@5Y9sifni|dH9>G`RBl!iPUIOoDB(fZ1 zoo(GPcT<;@sCcf%zA8qvH(^#z=RM2qiFQfXe6~4US;hI1?2Fp;rS_VgsfVbDM0+>m z$(FRg$9jv*zd>m=YetfV{or=CQ)#};V742TcRfON@|5@MS+Ojxvu_}CtR;;6 z<9KPGj?&o3v+Sv5jyyg}Sic+AnKOI((aiK%72lxw1I(M3zgP_Z=R)+H{8@7Ef{66+Z85S9U zJu;LQfU&D$Z`?l4BBESMRm>uSP=HQ@6xh&^oi{!A=ojQCSU_mpMJCUt?d47H)ov#% z8`@KI`kDdCBOEf9( ze!yK3$VB>J`oUAz$v?lfWI~`&CK#c93=i)CowVY~snL`;qLb%6%LC83GRd z1?SRSUM4zk{Z5Y2g8SLPx;(7)X@l#oU}Kiy7zx=JnQPJBoJErMgL3B%mg-SYr()P% zvfsN|1Y{0-&Bw#v-sRnWqRH8t2bsOwFmouzX(7)X%pJ#h9<;wc0hKh^R6=`Yi_ z+5mWvci~6jGeG&EQ?xwSS*&Ku{w?b#XZ3i*xH~EwOhs+wu8)dJlzH#SPMAX&`){(J z9_KM9@xzYM^6G2S8^1i;V|>AMYRXk*I8{g3*+OIXUi?{@Td$6OxTl1A+|q{9sk;zd zUNBTT1t~Pt9r%Iu9=2IJirl=@8}@a_6U0Z=6eE%KPkEqK!1#Bj6LdOV83SBZ>4M8& zL8%6J?sbW{(+w7xU^gouRQOhOohb4ePTVK+f%PQai&2RkxpAeG7rDN>Mq^ZCIN+&h z!~7Shcg%B_=P1kiALGTLocUqOP=&m0I@DFQ73TIT!zjXZ9|P$E;}0}BAARX!H5p*% zDb z@^QN+zSIifHX<5lD%&<8XTQIlZZhg%n;Lr(rw89WaRvsQA&Vf|v$NR>5e=k0Z?O_! zNd?yM6zF`j-4o$C%82R{O)Oa%geIHYR_*Km>!poF9CRZ&Prakz@Fk&a$$H_7MG{d+ zcrK+X7c*RzC-#Dj9a`@1OeN}pbW{ypn_w7N?Gcp2rHYguMXx6rX_}PqsW$*LE=nYZ01Tht^UFM3$83)2J_e)yMv zGv+HK+Z;J~K=-CeuJS2f40;%%JRsC}>w!(2L!y-4JI;LN^8jK1ax{ECqn5Sud;_oI zEaCc9zE>l+EUv?>7UI+J>yZ>iUJLL<=T4yWLz2AR50{!2;F@jpDsow0&GK^-%6K`E=z>ky{l6ZQ{;W4-fG%^2{(5P zT>MRi0N=B$Vs>Yyp5v0>nBa|6OSS3ZNX+ay`!{fg3*mia<(m zNkn$ai>KqkR%f$bObk}mMF$KO&Lxp@m(;^LpEoqOX`LDKn}IpzU_FP^&8ni z-{U;#WJ)&iRGLvHG={(@L?-Y>0wh!#P)1xsvXwHji#*3)t)({I#<%TP=r~37ox+xE zW=wNYB>w%OfQSLxvVxT6!_4r8FVEMxgw88`I=3q=cHZ!p84EjR;Mz#_$=-bd+j{UA zGK=d?z|*NJ0};f)KS}c7+gg*ef{ou9qKd_d2)$1~Pvr}*h~YKbbySRe)hkw2ich~> zMXu+%*DCJQR_@oa**=_x9pap%tDkePtjMk$`&B8Q?fHVD@%g7J@kGvE+kn+tW+g#$ zlvS{lE>mLIh7pYMNu+&y?oiY-OF#}|28GRkHbn|VS3K4GUYfG!gFG6oPu|Xv!q%E^ zUCh)xrXl^_b3FVSDL>MSx-2Z?S>{zQqt2KUgF!(-e&XkQX9>Orygly0lCvW7?w>Fy z2)QuGd0OXrpp7BIymTH+u)tJVbk9SyTqD&X8(8th2Fb>Nn*&_2Ng&PX@+%h3;?mN$OCHl6BCRUU-RfW`Cdlt!1 z{rp^&7)ei14me$F{$f=_iUN}h=Xg{Xuxhx|k1oY*E>`nxR_Dvg%b#0(av}2DnctYk z?CH{SB6`WK39|1{mMvcEe5=F~L*$mON$;`~mO-Y{AX&FYeJY}^b?|B^p_{Nd}1&f>GDBk>g0X6ci+ zA3P3RFR>o(9`RrVAWvr7PkCmY(xPdAN&;o~bOziJzT9fXStU1?ugWmf==_w@X&>;o zt!xH3&bOo&MR_9@olned(m&PIOv{=J!iBQ`1pAj*Wkg0r+qKpo=Iwr6mlphgc=bM? z^Ia$){20cZh@|#zE`3pIac@T`RsWSy*yCC}6I^yNaXiC;y!e{3Mm{H)~;6Q)0@dZ-!+m{^!>oM@`J zB5a1+-^NaV@veY2M~HTVOi*!tVpM78ewnTczxVoD+x?%dN`F(~g4jZGKIvOP)x1DJ zP;|auU<5t@NQNjzjQMc|Nez&0iQ|LB(5UuMZ*%|9LYLq%~?ayz40 zCfKT$t>6Y0!;}C@L9{X6dC z_SaReW_izh-tX7zd7kIv{NNAeWbgzs_hMjL<`_7+$+lzQ08@*7D0sF-Y7bD?jufVC z>2&R`>YB3+WZ|iU1r}gqK#dh^ft@dpZYKmcN8C&qY*T+$pwJv_W6bIR4sXc^?tHA< ziv+O<^($;Fzi{yMX8!SQ2?OQ%d0zq^;gp6Cd9i2=9(RR*a`KBvSqu3f(!@A~;KDo2 z_d{5giz9^?y8>{zH%`36;(AZ@_K>Gl}SJg*%yo@XG#` z*xpJIi3=y^q6b-jK`$xCy1^qS61O#A2ff$fv_;=Q7}#f?nnlDf>?{5wzbvv2>v@qSq9|beLYP1*JeAsn!hIzPosm47xbcD;&IWHLm za|yJ6AZswasu>V+F0v4Bg4$yWV8rhfy(@qNKlbSm8czAl(_xKW?rg=(jCSKTIQS0* zgY{dV?DLKTt~kqwtGB>eFdGDjuq(`?F?;#HJ_%)^X&}JAhAr95!ZOaf&e0?X{s^H^ zz;G0cokXbaYaB*u0;n2t*m`8H{a^%O8c5M8juh1v4={T0I>0mn{wlX14-5$ZF&6*< z9jv*QUCy88L< zf7GN0ExcMZ)x*xuAkx5;Rd=}{9Lq8)R&M`+;2kZb0*6%GvD%R zc7X=QX&&wISH>zl{oaL5YL|xAY|bvV<5A2=Y~87@O*C_msHPT2N619aK38CQ$a`k+ z4j@gKVOEbm($?SV*O?8}`j?olftl;TOj_`_u2k77JkJKK=Bgz{Sm=X=717mDEJl?9 z*f1vaAae<5$oR#B}INM^PK`i11dgggDhwCu>?oUG~TzBVbCY_YRzItt1txzfNL~ ziHTHWEHlMM!1aYkxo+$}_k}SVW z!cj)9nQ|M|n&dPt2|l|`WWfr49Cz$8pnfgFof6(g5vGYh-f3Y7T!2CUoK}KWx&S8wKjoX1UsRv zp7O-_hI75%0ts+ycjm0_^-U${m7)1RwQuB$Bj`*JK7z#dFn2_So|vk~s5C-5%93KLkgcs*dmWjd6E+?zFf9{@Nxdyv{GG}GHh|ePK;dAwV(57u^A#7 zZis_nhnO8}WiHmT{=5Y&Rw$9+cN=vUy+dD0`HUIl!}?=}S*NI`-2lvT(TaHqmjv!>mFxTr+$tu znThXoyQP?D*mX*qDwO1SM{ZbTf0g%!^tV9O3VHs_`GuwjkHr%RYdKi1Oi6>Yhlp1% zf%kYIuM7IPmCxj)OrsH~`)|2emGK^(jxD4@oBJ*LsWRlb?7a16YH&=BPr=glLkVs_ z@5^Bpk0Skk^ekViQTg7dHxzq!mEV$RhJUp(uJq_DNveRs!ty%D&vu8V@%Dw|CZARY za$P=}H{hZS+CFHNwV{>6aV_YFKjijJq#Sg^yniiyB*r*4qp*Ib;H4xTN9@Q?uj0{P zHbX(3g*Kw#ov9jmee|VgTs2zHYEC^%Yjf7^UiFaL!SwG*zj<+?0EupHm|e4v*~%JH zVBCqI(nt??S4|6PBHI|kwaTiw_CscIUhALVuqV%C3JI|WCHO9w}u z#qyV35wq%YN6^y;X7Y7+3LV(;zuR+5x2)}w?JHrySr3l|?4##odWy=SuDC-a|4|X? zD&fn@={h^rf%xq#`}uIJ%qa$eUTw4Lr?u*}EW}B@k8P0+kD}yg*Rni^gm~V`AVZsQn^+$rE2!nYWY# z%hFrIEWRMx&a`aVEad@n+sw{XXY#mQM6fdwJ+}=-?vo2QzLBOsmQRTSVbf{R?<(&M z$iNijqeEDZ&HuhNU3atdsU@T?i9rJ7H+6d(0}t(5{DBRwEv9irDi4Ry|g%*CW`wtv#!XYnyB z>KW$&a+KBxm?_ua>R~pVIH$c+!>B7mBeO`f$W^se2lINH?6aCamPM#)gJlm;GUlQ+ zBg>Bn>&!7t(d%z-Bsg;V@VazL&|{VSz4QAt$e+onOp@`EJPXS&0panJ0H<=~N2D(# z4{ZNib@u+r;PV13+L&XX_2n!Ms12S+r?2ja4aC`!J*hiB+e$gGooK5k5tbx#XDg2I zT5(*u1kT%jXpT(pOM#nUGfhM~XesNECvVx=nEdwpE_dbrxbrs3m8%-OXKWfkfq zXg+BMiWWdNVRq_HR++he;!X_>W3RXQCbOk0u#okUl#^CL<&+`$%pT535zwD}C8e-j zJ)|HWbnGhu zKL6mFvJeMsJRbb*f2r}o-`^JE30QZ$Y&~ut#Sm^?XK5Bt*An^>y2?85%T3lgewsdJ{4tWn&X)x;S3j!SFn>f4@98Tx<&;#dq8Bo&6DOF6xu@f zx31<3%c#&&6id(s>mS|x?xEFcGf=a`PW*CS`<9r-(^Aa*kl~{%Kow9)L(s$;FrkEz z0ODbFUy)c|_w+(pDhta4zr@$kyXy9UJ@iaiMz~m&{)et3JOQ$s#{i^ol&FL@Ex1uO z<81T4#LA;M1uF0tQ~7|%TfJ1iFQdpsBLPN8jOm%+ginC4&f{x^y;NM?--n4k5`P*c zfKCKrCa|2tq9j9`RuyLDR^q1yZKVwQ9ggFkI?M6GA_sQlQ2YQ2GH{=JdU_fIC`$c} zWPIPfo)GaW)Ou}V+gP(n?w;dcJo=2PW3$hIvJ2PpxouM%umz+0_1{+5WJr@JG4v^q zfI3g{w9)63KCN~61yr5=SlyyDy9QxHk%3`{ff+2#=t}yP?*e9$##R&fxG3^Q$^qy* z@mr&FW<*3LU&xT8)+OkLfu=)PDB}u0FVDv?Od6KEWVYH?+1+xlruz;>jkU-I2C1g% zwdrXOwegRO>RUyR{|H2*j2h-kagYg_$Tmtd-CS27_wFT8D}%K&bNw*L8Wou%^0TOs zfRvXaTd9bEAlNuAGS9OWuJ1?_qr!77#q}nUD^nip-r}&*?mhI@eQEWmSj!1$_r!I- zv-&0+%(E!j_tsJIKDaiKjoMb1$BJA`4}&)z=D3mQz$!_iY~B2wd%PA0pLF0swOJ~! z@ter88sziiRXfZ?ESJ}${LX1)XZhd-7w4z5UdnrP!+uX^tXMsVt2KtjZkO;Hih9%5@%tqpoY2SoUEmKn*Mmz22!k+$ZR zQ#NK^K4MEMEH8N7(>NN)8fjPBzj9HQBgy>AS2z+a>u>kSX@W*fF0u!^Vz%G+_-NFMJCAsh{(7}{(sEOA zzT^*qv#Q(9_qlY<*byK1)Wc@g>&m#oGLMg1JF%2)dK*ro{62N&P++{4Ac;#0PIg{6 z+1cvc4(xbDXF1s-1$4qp@Ebr(t}%7ixm;RcmR=tV$f&cZgwHPgQB?2t3u}NBUJxyX zYp0zRG@t`oU|}Nv=lw<#$Gx>Wn|rW#2o zIY^YWBbvMc^{0EvpOW)u4oZD=LU2c!B|q0i8Rt)g&yq=L_bcTB;zXoZ{Bxncn>_W7 zIfCM{#8|5CS7Mbgirw8i_~70;+nuk*UX5SJ=KH0T`8dG6vr2sXNk691HD84pJA}#2 zRt2{j?Y5yka%#rwL9U!CWKjHmaOadarr&hw6iDNehf}aa9;!AJZar4L2w%JXi-t<*U_Y(0uyTQ=k2yq9m&c#AXM4@aDCNy zDLiWX<;_*-d~xY-cb)y(62ytfTR9s$ESW1HMl9neoooKMBar1FKlLhWn-XQYY-d*X z$gQv~Pg5_$a`Mzp-El(9gy=lHG1OZa!i>22IO?xt-nnn=p*hxtDK5m$z2=O*ociqD z{TUwyBek@Q?(d^4=y=jhhlrO9)S2l zUpt5(;347|_updIf`8q?i!$9ae#i*Nbd|O|)J&B#2##k!0))-bQQZER$sC8~*xW%} z$s6g9=OM@M)kKA+&Uk%z6kzYr!;T{e2lAZiVxdMxgE$ABRZF7hlGL#1wdS`qgOYJO zap{hqs8`0yE@llObfj0?|&B-?*$UZA|t~ zO`bBjlj@rKry=zyjgGHiUghoH!K@@ zUFelNo-2;xl1$5{-7(}4?v&v-cKPEqj#RH|%KYSYafhGc_e~92cuQR<*cI0#olft*E|f&bXe4wfg2#T{$;sPd zY2;0FJC)?X2}RVDe6hBtMkgRR;ZSPIj2K~5ehsPKfpH>eyT3T{Ke@s^`>GGaLJ(G4 z_-=Y_n=siTY_ckd1cFlJh`!4Wy7J835!XDMHBmRg>L}b)w?~hM18~Qj$&Fws9p{T} zzEf1uLe_<}%eqx{M4)_syS5FDuT2_uz?2Dsj(NrjNbGxOfbDlJ?u}fjy`>3=gkQ!0Iain zjqnhn)CK)^h-g|cBNYJRl9})F=mUl8P|~*q8AynMt;J8harQIdqsSOW@*dx@u{zJ{ zx}D{L$a(dxK{Qh%bd_WEE`A-~k7jt6$=jXq(a4i3MaV#ev{z>dH|JDED|GEWa=Q&OoV7`V(9K`0b zVWywX9ByxIo;rT%A6dbO`5m#)injyY5eOv3V&N7&P@PzB5~Ku+=B8zTn2~RBceuu+ z3GLqEVF{=hann-Yw{L&y8q2G%+aHQoH24QYF&V7G)1U^p)nwa4rP=j$OmaIAhZO6B z=%Ba^E9lw(gtiy8s=Z9Q5amx|l7$WSOYra@4#I5!NGP>d^Ia?vulqJP;rs9=<21yu zNQMXh1}Q&=e7cT4hJ=F8g#l);S}co4(C{rep!04nVRhpHp*)fxEI3vC!|M&aO8#kp_!LA`&K=ga1pwz)%3By5jgC zlp|E5fRHDA^Sp(@!Ls$*(7YyCvEkdWTsV~R)Z+OX_F;De?2ncr$RB6v%F$qe{6mbn9wSuY>vo0N z<`@XfG-oeXme8Mlc*Po?vW87d zi%Cysf`l7?p90UdxKP`~==GaWPXP-i5(Uy#>CX9xyM`DFs35#sTbr9!T_G*#_jOi6p85ToFoAV-0D`8unOk zMtu+SNc8vX(M5w_!^@aT*SC=BXINk@3r69aQ|4VF7S8nUpJUH8LV0NHdc)6|`E)mw zsxGR6;idW*y*PgD&c&XBW~;d9?8OPQssji{us?n+t=r=f zr(e{{)Me`2&C~QXU|-Tdx9?)4;D@BYZc0J2->Tocp z{b;1A$|jZ9R%x5BT8HT|u5ND$B7P{l^SwAl4`0psdPaZC*P0mMQ(og?7N(^ArZYgR8`nbnE0%bJB~vq$W=|2b)56- zqZ)OfY(QrbPtoq^lb5vtom@=`TB^=`l6L*Oz;^7@#r zt{m$?&-Ta-(m;7RJFh%uo%K0dd)mudLzsILduH=!4B^zS$IboY9S--9;aXQd^Xiy7 zYsH}DYB2CM3-rLfvFi^J44>}oHlQbhl>?ZGCBLfY0`B?D{V<~S=HRmmqUJC%v`e7^ zHYNmD%+|2${c`iSiLzSr7Lc~nLsuP6m5ciK=v?Fp(X>-fb`_`dRCq?rJ<5J2qpJ-Q zcFNDPg)Fb|-M-f5J!OF!)IN{Ces2?|;o8z4LW(HeaQ%nq1cF@F zUJh@o1>_fas@FW@oXRD~#;eCk_=N_DyTDDcoTof)`$URzp?fc?f^+TM-}hSPe|hgy zk6X5xQ_q+lkZ7B49Qa@h)48(Pf}vCso{+{=xR?T47W5F!$k!IVJFkdlv!Am@&!@{L z&fQ;nUaEW4&q_d8n_NV<=>Xj;86)`i^aS5~!z-+Q&OUpNBUlgnGqTLk^9JQyD$6|N zrBEuvOYbW_uIJREE_yAVpnl55&x5;fT}~DqIPRT~Fp2SI`z5NWOC2$z_1$^g&s~C= z8ZD!yDv9*>4uvq^Fn&%{;CLK3)T)P?PD@4j6#$aK z=+L-n0x3>bsc74Ei?eqza`-13Y`W-%j3eGf0f5=DH?CL z|6@jikS4EPhT|2n$Zayw+xmErBj=(M93$pDaW)A@18p#ZQkWsB6YDo*2R6!PUKz3kNWT~em{d` zd?F(3+6a4&o6Vonis>cHZUU>gaZ{5YC8%#6+A6se=TG?|iF~E}!eMq@KjuCCphnu32PMv_Z3z9mMnL5yu`g*)L-hMtdGz7M>80qo1R~zf6ceXcgSlGboEa9YjOE%RT0^1n?tOp>WV{o0 z@!zoMpEG(|=-U+C(y8z!r+KSTCS*YJ#BzZIf0sfDPQ@teGV$j0q?*tAWTpu|GdFg5 zJ-EoB>Vfr|`JVRd>y7YAT0?j&}2&U-ynB?ARL~MSo z-x0knR$*-9C%4)G{rO}rH1g{b`vL5V$C^Aqx$k*kI*^x1>{?zp3@}fc)E_}+KBx; zdF)8k_U65xs~v~hk(ydD-@9eckS6GRj{Az>)_>@%6&Q9TER?w-F++JNa{47RJe3pp z@6zgHs~RD8ZB$&I>U`B3^HF6%4~C&-4M$j7dKNNw4wu(Mj_UP7JkJ_Q+P3Q zEYhN?`N*`97xOaGAeoCkpvJfYhb`bn0R&&uoO_Oic-^pVf1h4*u5P4cMq%~Bdl!Fk zfL5_~$4OvbJ0)j}*7i9QO_T;#Z+B_|+gLoRyqPvYyxV)^mC>{Xx&{JIf-Ae(&FAe8 zC`VNx{90uw-e$tvOWZyL2hRwyKQ$%PI_o42(BGPSB5NW^xf05OYt1tUT9d@IFzr8N z)n!yP1>R=I9t!yD%}geTep{*DY||wQogVNjQ}tz7eRMyj+Sv9Z-998&UKfhPhnr7{yBtcL9t2jI?>YaG z_ixgpOFkH1DV(=|wcH2PKY}8G!flre?WsuG7JHDOawnLIE%X9Q`0x!Gr z?pp{+u|un*xP*j{lg-c9hq+!9+Q=$u#(GrGCOVwe?;oA){)t^*(L7X%)-`T9tJAJq zK4#Yw2MD2|RKfXnx32oh`R7t9#OF)vzYwPX1b~a>Gk^P2a1AwB-La8dQtE5 zfZz&aV*~>q_fim$2T&Fb8#*!a(FT20@Cxxqp)Z&xH_p))Ry^UCfOygSqqbYbIpqJT zUQLMb+Uz@kn|I~27P4x58hw%(CEADszfwVgfY}m^)eZwnTI&TTWC|w8wL|_f&RZf$ zql#@5h&QK8XX^4E<$hodnVfy9PfJnN9aqQC^Bfr+dfSt0lU(2HT z)cli#E96@-yl(e}$+ID~r}rf52p0ssmpo!9-9T}!=FlOq?>gwJ)H7ad6HvvV2 zBAui&&IITFtR+KLMdDE|E7h@={(=DqqMt$qKoXPkR^n%L@b4$Lzo>OLl7;zerb`V{ z4+`{V)0g-Of;#OI!6sG5<1D0dy|L;&0d<0(_jelEJva&ebB5MUzFaLT#VM4Z}ORkRC~5cl~i zOow`ON5}!**5iQVHT$XCZMlSF{j3K4 zDIhiMJXhmBKt$^UDWzE1J1r0<1aFk_Fb0Aq4G`HND+6QVH!#dK2@>OHY@{(K0%~d` zcvf)zJ-0KJN!4qbSBCnFkLUT2Nx26v({1i_A8g~^Byn)TD=Djkz^H6M^^|kDQz@xx zZ(SJLnQfK_h^jBLr$6F;V`n!;BsVPSi8Z!0zkJO)D+1o+nUxXtZ%|vo zum}f{32R-P$D?|he+-XVSP#~1ZRTyLpxyMss@N%v=4gIo9|*Q!aQ-7DR_9SDd9#TsUQ!=%g!!2|5e%j6NaAdjGCpx| zdxhlS2un{rbh>-XqgX$lH`e=sg$<g7FFtJ2b-!|AyjKVG(7}GE%)=Efc`@3&=uVqK!w>#;6A`^Bf)|SK6NR=KM@Q zeig=tDs1{`{OLNvUdw)Pwlb9xNUyQoH6D(8`G*s`Ntyi=;q#Y{+WV=;lSdEe6K0&2 zPc_h&3Ge~ItcDr)#Hcc5r@EgvUeuK>DMx3VJ~7r@9!WWwBoCrO`B$3vi7Qsfx_CGd zncsx-Y<|?6s$LBqmLM!bNt?CZLGjg+4ubL{M(FOfxK~C#D>M75XQlK1G^kX4acez? zx7NxDl9XIhueCMpcE}L8+(C=*G7o@~%Cxb*E+K!Zq|3Sf{||85!W#N~XCtzJOsG z2!aqz1qUuTHkd6l9Kilz$}ubd1fx-Wfs3{+cT1qKRnOmFR*x7)VgD4}w&OovGR!Rx zy$0!@@$%zyc#5{zdiBh1ikIqb$$AlO%b=G<-~@rWjeachr;lRTwy^20aa?xQlMY3_ zTQCUfa=gq8cRmt|ek2Kc($ey)#1Yi4t!clHtWUA5HWNd2bOCdMGFWam@liNZV|TO))jVywfB+OjQfzA{=De*d4A}Xcip7`Bu?jsh)Yz41c=&`*rn^H@v?ykq!(P~;p~6Rn9#C9%HJ(CZ!%QfPCBkF`0yv%wWZ=aR;roeysqLep=TnWgFp z%qsIrZ*t|iPmC#&Kl($S^rH7kS3Qxes*68Dyheq|0rtw);-&)dmLKuiHnm1phvtp1 z$Eks9EW-uea>7N!`xh*N0-`0)T1r{31Qo2nEcaDb?Va zw)V&1GvisUP9)B|<(C^L3GmL$>XdKo!3g^JPfB;|nGZ?k%|GCa^Lu7$seB_8?}|Mu z4f9}7eRr;VhxYeL33;x~_B=>hGKrZc(C2nPzTjtmMoD$InK+hgPYh0|n-D6BdP8q5 zr+u>dSc=h^osmQJG^#bi_RL-89N6 z*Nn3^TQB?KqUso{!zI{~SbWhS@>{v2+iH~?NWY9s9 z|3`mSLA7zsyf5X@3~(iY_OCxvBj@V8nRvfV#7UUk`2kaN%C#o0rg3Gw- zs%TI{33`m!t*8pH!Z^x#r}D~rZUeBdWP@vz!K^G(XbrGCNGI_Tjrjr(-*&r?x&Dn+ zl`pWO$;9H>fSUOLvE8oHf&*(VD!h6>j1#LXSd|%OsybMGrA9mjKH#}AisIo`dc6bW zH9^L{Z%kpL5Ja^hq8`ho(d3zPZETNamvgxidj>$}z~rF6E3b3j-`X#x0CC$cnOk^q z#?bpd$1|_AE9**m7>Zh--Cf@U!MGNE@r5&N&e5Z>7h+X!)ltgzZs^i$YK8i(1UXpW zY9Xw99DFWhzN%@daynp*#SCxEU&mPMP6rrwEf)<=DILKB3DGQRYnDCkfdR--LK(f_ zfN7D@UB$|R&WVp@PS+QALRA#mC(UHkr`4nYe6g^=ECLV^ri%+k=96oLHWP=kpZ6o8 zbzumPFQ6)5;&wu(7)&cb*#-NfPnvwxi>FZh0uPD->-P}EQ%8Qpm9ImrbRx2(_*C~= z6tqP(%7LQLWkz5Bt*!rLEOKAQvNwpH0yqmah_$_PRjU@FcP@(D_3=M=abeVii5e1$ z#0V4z@<}8VCaQ#D`8+xeQ8~QT318BWXPkEYU4kOly7nBNfjb+{_l|D_1gHS1hX=7$ zz8kV0(O|?e(>RUEl;<@$2 ztI`rw1?S0w-05Li9`77)H{x6l04^t) z;LHx5Ilb37N$gSI`y&q@+4|haAc^A&dPhQT`#`S32dI8D2ElEEpnT>K!)q$Us_P3$ zQXg98QeT0Q1ZH%St0%u<#FU0w=4(2aHy~2b&2>@vZtqTZOJn}>3HU%HI72lcrCQ*e z8)eBFZHf?BMk7@CmjQ>F5|?#C7(KUk*5-!&mO?CiB#s%HaZR^nt&LGm9{!f_xd7+( zvJHjCGgIBCr^L){7bJ{)u+f3}RJ^EWiP+gs=Z*1#mojBLV5nSVY3>W00}%z=_UU&^ z;HB?`mo#6~8W8T1mm=)d|}J51qFpzmc$UXL-}9y`%_n5ZnQa|bVhtB!vonO7Z%%$D@y=t3IAHVE~% z>>X)SbT+696`)p1Ub_Ti(m`u-*fM{%c8}$bSJ;Aq@aY|H_smWD75dx0S;v;58?U0c ztWpEsp?(6hZq8w{q=HcOql+@{;ql6R2_%TL#L7$j{m$j6gO+}l2BaWFG(%s$1-n2eq#Pm3>RT8F zt4k9t*BnwWsf9WMRCPVIg15fd3QKt3Y00N$@z=yjTINZ0L1hevQ zigpr2w{>~OuTvAViax!t^9O=oNgEQTnUdHs>vL?MS`!UT#SRA%SN`m+4=4^!^Zqc( za_8;bmk0dmYb4`I#;}Grc=}-Kxy2yA7Ypx{=@Nx@U%>o0B==titYx6+41ps_6cCBe zPwIP?W_8KEtIC|9d%hS+6w-1@S}qa`j}dZEdFB}zI-1m&4~f@oTKP;(0oBi2K1G*$ zTS-)^AHEljzkf8<<0q?{e^6U-kN)t<23VSIIq`NW~~=g`En3Sth3z z`U~8-XI^er;5&aA+kG3U_+YI2w&e}|baOj?{f`vtGlxEUM83o_`meuQ=k-_X$SwoT z!(1MD`gvdwkpGD!Ynvx6 z=FZGrHuxnJU+JjWobwtMxbPe2nhY8+xfQdKKn1>W08|ySS)V=EpD>QkZJ?hM z?ox&GwVPgWHX%w8JOHEk1$+zZ3y`9>nujL{pa-DgYQMMx`jX0i|0%oT>mLj}>89 z9x{6qHdn_4lfT`CEVA|AgclGRg`XfBwh(od>Ii6oW`-fbCO9~_ZKLril2HQNby%Zg z4zWBWL+A~t8xFAVgxxgmFRy0TS20f=wRu7?Wr8PlNrt-&S~!niN*YtNMnXbSGhpt? z*v#D$hkUAn9*TcOwKY<5Ma`AZX<7H3eF7LYPf7$G8_28YhPxTu4h(KUfdXVDI%a-s7@CgL&FgXx%{9Hl{DdT9nm7)1h ze1m{Rh$sc9#NBo21FtGfMn?3%5ha}_%Z*1^Ssrk$#~|2@6>m#V-|XI;$gayaT;5&eHk*)Fwe??cLxx$ zhPda{sbrWufwMpxtQcF)-tapb+INl2%*dAF_0s#CufRZUVR^$sea|js2>)Qs=6s!vM>NQ#~NR!BH~aIQa#<0pPGR zXKkeYdbS6XdYlWrBKTPa5)4O~R$}X&Cb559J)1Bz`tu(wVC6CYCGnI($g7IHP6)$- z&C|&V=7wS-rxk}Mp8}gN3y^^u0Flqo7m1@8r-SAO1VN7u8>y~%R>+WmLpQRp z|1fkH!#P2lf%km`Wz8%TdY~ZFJOX1y404T`TPP}|bMCSc^NM~*W(tpG^9JBqQON5#% zN64gam>&c+bL!_HNjfEBmIsqn;KZbGF#QVYaW{Zl4P=ge8T7scSyj-CQ(s|M06LY0t$&_xDnElT%#3M6@| z`*huXvTB@~1Oyq+pJMG=_qb;X^fQP*uII(AJk*Sf#-yXUySzY;JD>C#fGbyXTqOAy zCUo8#Gr{2Ct(<)6oo1EyDpY`Yif)4n4=;WjmHg6gk)cbDh2!4kJnwswbs~EsSnEE# zVP2~?9pQ1|)pOjjHfL}aZ6O#J^8ioX;Waju#zMqL5K^N|z1h&`s*0=|CLgte*~1l4 zwX(^U^g74E@uiDKqc6*>^nm<0V7!j#3di=M%>BW!Ha2Fz>6!_@#s3Pb24s!HW6% z=Y381XInj%Dc|mvnNNB=xqUUZ=~A9^Qu@P~v$#_ep?a=+f>{OA_g$*xk$eJNdT5(N?JJtO4O5A>= z*&G`RgtxmT2K|5Kxp!N}!5$DUm63UZ(2Nlcx0GJrgp6w~07-(2HOOqQb zMdg-~xiFTClo`e&T=zk!SgraUK4B|<1d>8$`XKxXp6;s}(>8(u4eEyg+5Zx_e1{2< zVkZf+-Z3kL2@7=-OVh&_UqZ*w+)1sN?jKXB|_XvhE&8z%Xz#{d?EjIS-`-HKg@u0g=vCt0ZJj6QM^DQ?i# zb=L|e-uZEnqP1`APSa~>c@*VTf{mz)aoe1-iL-oWTA5{KjqR_Tb0%ihrDdleq6E99 zp`llDuV&Iwf9sKI;J+1}J9#ceWCDz7@$v)nxnt0%1DaTDA)x?7Sus%%> z3sg|j8*h89f1{*OR>b4&!F~_ER9N+RJ-&DNJaNlj#1_E~ZYwXiGbMivZBH(20*(4= z&#|jd#&jt=Qgf~(mmdPscB7{yF{haUUWR_OF{4NRCP?)rV)oB^0{f;~>!YDH)SEvp zyvyCZ1e-t7nUI5>n^fA}G|5iEpRI)mCrW;CSUdMi3SlX{r-V>^V3hX^>HV4~XyxdSN$MwWB#FALMZK7@_V_o{O6lh|$vS~Y_i71#u&ns}IXbetj%D<#hh;ieRPWqwa8MReO=x`+=0cX0ghXSJ zOBE5xudwU5FDrYt4x$?iDaN|hQbZv(k>kPYG3{M%Qmrv{SZ(gKyA(;}h)@635(zPiz61J z1bO-AjqS~?IM;-4WBe-@bbsf0e_hbM>D|9jX04pYXimmaxIY0<-oafDp-f@ST^K6r zNZ>|dPV{i|*7yrWHu-cxW(`FqR17dgxcgjpv>8n}#QN|};LT5+Wqm_uV>g0Mw%!K! zd0pA=NyM{VENYwqK>>E*4Hp?mDVRK2O+v4S0i(M4Pq_tde$nk`~{W zjjG_&VWm-8yE>kx9&F35b&70E-0zb{0xo_Y<_yublzCV{ zG%c&!Hemm0$42vtPz9&8wf#QHR6U21pFFyIuG0JUR6C~b6c;kR?}rqlNaHUHA94Ow zRZ*x7qJ-IYS4*a2mNVYY!RuGdWR?9`940is5WWSy6k$7MP0Xzc2EicUOGTVWop;~x zeWoS;t-s-=A(59l^L_ZKMlbZL^T@9!`f22MLl>7fMu%3cX0Q{CmmVtwmxIz+u5(aw zvPp=;<=YDbBh8XQ&P=a(0?DIT^VnFq?+3VrIioYF-WQ=bxhTNI5AGEpx=TG5vDN9V zGM@-HkAl3j3(M?0%wsQSa|xYb8e3b zN3~tbF1b+jhMJn%>J_i!FqCdO7x1@kC^Uk{TBm$#RM2;+N2kLi<$IFfxiN9-71moSf&kNB+>5Yw-y6rTL@o^Nmq&am`TDPBDAnqE|L!WzzhRTOP!YJ8JnI< zu{b{kK15HQ$k?cP&&pnw006(>o^rwpZ8!{W%#qswLE=u0ufcr~v2x|(t(zW>S-JJp zy6C%I@1Q$|<+aThIh)Kit;U;^C+q%laQx!R{&TedSBoTv;tOpem5Q1x&`-ur{CsXx z`FHH*^x4^Bs!6s-?$G0O8E44b7Rs*=`*R0q`~6|^Xi7#YBsl!qT3dv@0m}ox0LzU#zUhpWW@$z;CW2-rGh{|Kt(EnJ^b69GZPoZkD^ zIh}8B@{mYmatWEs^UX~?2yTJi_>X8#{pocrk%R5FMe7;0 ze$8KNnL;>wOrUc?UK+^Rgtd3G#Y$t&i2i>1jI;Zcs>W&O`nKZVGEopy>=of+VIfHC zwAUrUE)b+vQ0AB@NXUSKQF)qP4=GuhfRhC+9+o3r13(92(}1l7Yz~>wU?}c^k4K%x zR`m1!OJ+L&sMoM-U~U~DjE3d%?4on+gUAd| zpuaw7HY=Sfojc;{Kj}S0qidCf#wZ5dWXWJ3q*fV4k6qT2)kFA zs1L#XECDCrg+KalL@*e;0dv_jRQd7zZ~q6npjGVH2F@-3ZNi$yb8*{aJ5w+YUGsMPxCSpN>Lh}5TswM zJKLnPHH`z%C-oXagt$M7`U=ot%Tha8+MfXY`G1nEdOwQA+$@*Tr~G#@gCj<6mL`*7 zT09{OD=Sxly|mSH8B*vl%ks()N4gYH%N?Vh=p}vZIU7}%_3GQ*NJcQ8m%y3Er2(bq8FK@Q{#)g@<%5&-oFYKKW|twvPVs9O>xGMEL*GZf4HZH87>f$rAyN;?8Z#YQu3!+HCh%yym4kJ)UvxR68X2+e%sjYO8rU zEnccsewklKU)14HUM1AGdFrKnDjz|$mC+YM@E!g9x6!Nz08bGx6f$w~0CN=+~y zF{qj}%M#R>$(TPy9&2v{g66$C^{{}5^!GLL;5(UNl(nr${>6e3Nqim1rQuBDMX;Rb zf0!%qV+q0s^ypT(;pKkJb=#`i=0D+9qU(L-#Ov$?^>iRD`Ni{lV@DYl=8HTisyDDwD|&x-WE$H(ry^h$??1KTH(8@u|x+34wy&+`bc?;cG7 zMZOo&^{m~z0;!@@j&f#L8TBYs>JY2HdDqpq++?MM1df@22j)MQV}HK3Wq0Z}mRn!p zJTO`htE3(3fqahIgb?fXInlBv0kQ1 zA=7S9zu$A6>pIst z*LBWcKYzThddHY~&1;^|=i~9X-*5NsPeXnu&8_KqYv&Sd?Au9{tNaTqEB6=-L;a;0 zI=!q|C)VWW*ClV%m+dRE#3U+z%80swfpcv&1%L&Bg~LKYwQlga%EG^l6$%HU9eFc+ zoEnu*8IZoW)JADGT@TI`1_26}032ZWss-ifNyWjM27{G~K9y{u*8>>`lI9(r{#hm+AQwv$mrJ=(LM{!w zJ%}1FcOY+HfW;ZoAm=nM;jTeyU*QXK%5?9)kmv@`Dqcd?%Y`JA80Nu3$i;MI=%#S z-ZV&YGHQh}2HMNI5IRJgL1c1{cOCH<*j?=1i%b-oS}2+5wb3RX zjlC8bmwI}UJztBNDL-VVjEyd6qMX5hYd4T(a}S?6O-l!;62sQm@b8$ga5NPi+V+gm z^r3LlChiQ?pk!A^Ux#X?`Lf_vg6JI)Iwyx(xL5ARJkNk!>?M~~r4>E|49x{!wm*c| z_c0IgF|`6_zd+l?nZK}gdQ#B5-I*U-nZp*6|0zERb*ZQnjfEVE`cQnyl2`TI3}4OS zhMTxCP3bp|?T=XL${o>B4ucKd3fVG+4%?1}ZuKdVAmw0|fi{e3F{XWSZ#aZb%M9@J z69cNr9RtUH5eZa?j(tpU<)mFTZDB&Yll&zK8GXmVg4G?9tbB9uy?s3RXhBvcQh^AZ zvxv5d&sR)wD43{k*#o7R;MNZCv`DsYV85=N4$jrCFbH?{c_4%@2CN>_S#lkVcd6vN zag#^yB7TeH$zAG1Uv|9stb3ap=FqxzbI4?IEH71^a_HxaJ>c8?h&M%A4#7SM95jNB z$KK0NP}KLI4RhOlD}PVVV9vJ{0H7j`*7fFx$=fCS>q?(}fEx;OD%n=iW1A7+93s!{ z+kA<*JaZ-yUwE_GbGW2pdS1ChA;tm7Z?)qo{GuEjRBoO>jx|&i{En(9f|fRi)z)kV zUJjn^E_Ikp*{!)w=)A~Y^nsJD!=i~JLOqd(QSi+32 zkf`36r)dU#_ekL`CetqIpYlww755F9PE*SWi3t3?h4aF4jxFheM_k0Pllo_qKi%#q z7s0XRtb-0Fq99Zlf}C#~IKl6zHY zA_YY30I+Rzj9?a?3KJl?BcI&2Lstm;IG}aIT3_ASow0OOOdQgfnQShYDcp7p_+yT)_PB3C zp}!e^#2i;yzjUpJ-{L5o_woT}bny0L^`|A#iA>oR4~&t;Ak8d{i{t(_82wz$S&yvz zgbJM6;Y9fBRGoJnIq329ZI1cr6ygCOE5a8I1HI_deRPL@c7x2~9?i!B??>6W{%?d$ z(04Wh^ConsUV|zS`rdH*MR&9N#3B=lyEWFms81O$w=32h070iu9CFWQ7@^oE7a4j%;fM6eKc`*lCK;WM+5aA+ZFC0O;2e z&ALlG4+yOPq_4PggW&Lu^^s00vZoSc$H&?5B@U;c`TguD(uwCV@-cd745v;RS_#p` zN4uSkcjWNngiDbR0!X;OfJg#zsnXd*7`Q^`{Zuei)o@UUfK3nWb-V4sO4YsaGdWHn zN;Pl(Cb7bBX|30kEvBaG46i@pFykAWw95XvzZt&s?;ro)8f*XWMri*IyPi^DyMvho zn@4*7*k;K2SZ7H7eVZz%j1;(-ZJcN*sk?Px6n~uhvU_br2Qq*8oxB#L`kxal40yf&c`eBpo!kwl=s8 z@SUw49GEoF)+5c4-*&X8+3G^$U1&q-WfObm-!k+XTz=CUziQC5J5pgW5okIFihHD4 z0ivRKi2ZQ3)ph><{-j)AC0I@6j_$n-jTAtR&YR6r8TS%Nh!Y9AJ&nWG2hJI?cTCL{ zh^tO|vhgX4eV=cn&4QjFg+fmmYx(dWKdsta{VopEa0DJM6zmFc2SUjJ^%p3BJp(s# z`~*6%(-zO@{*K4S2cdHVO%MgWO&q*Ia-4HN;GTsXROOJZ7ulc7zhD2|+C8i?!3%FL z%yj&-s{PkidP|+l{uuph{XGBVdRLW^OPk&&u}{BciN~ z?=hMu$68?gC;fxHmi5l2MVSjRr^4aTi(`s3KtgkGlA%Cg%A%Y^k4I5<%oa%sG&~Lo z86|^i8dOt;+>k0`W!x{-Nb!ek@S(ken`v&AhuRZH@4fn;3^iF#487iaE?pc4B-nB!4u&U@l_eqJC z@)SW=D_om(ef`H%VDqJ0xkP9%_j70-Af@YhxJ}=WPd%k0<~Kfd6rSwP}L!qNp6f%{ba@^ldz1|X&0+M#in@KTa|MN5GP*m2 z9I(UUngJ018gqlI!vo+8jTpCC9*ZnEu;md-1|59bu4b98*vs(~-8{`Iu85h3d^SQM1|k$2 zEHV9a)7^Oip#L`OEk}epc(2mfgwP1w`LZM%!LdhbO(o+y!(9b!;(9S@{q3+Im*?XT z`>3xtF$FX8KL|xSR6%)l%K{}4wRT=yNa7|IbWKBrFep+IpNzBmZaGvS2G0r@ql#_# zceeI`m!wHx6vFO+mx-ZsN72g~?=DWDd0Jk#;5hoBWU4b*{!D?(;xl{PZNz)5%&cIc z0rV%&_+Zg?HQ1uNF$Q3WWFl{@{u*Cc=%_e&?1g(!L$$~CtPZTv$n=B+BA|Ow;qdfy zr2)KlFruzbdqd2U(shy+;o?i&hQW-yufG@iip}|l9&ms7D5}|QERlm6op!T{|LBv1 zqa_tjqlsMfgsK%)T8{7leYIy_8d*}&#c&`>3Dt953Ir8%W)e+$b_-p>RF{F zE>A{Gv9IhKkFvt(Jk6~9Z%W$O?d3Wudh54}?Q>P(BQN$`HR5s$yIIuy>;$1nN~e>q z8Yfo1>Cq8Nc|d{d^hXC1Nx-4exT7-ieCYtKLm*Oe+NllMIovVU^`)lv_};j4l$K!S z+VjGQLD~p@@;aC93PnR+tv$~}(I$bIuy!6{?Zt2?xEsxrS`Kz$Q-P+gR{tXuT+&Av zk2Yea#6)HvC6NNAA(7_ILZE{g=dCWgo=ZC&^?_}iowEP(JLqeJ0~bDD+L7tNVQmfk z>73AiSb5qr_W`s>K!t@!s|C>^v0sjko=aN|gEWg%!S517b*{B4eVZa&eg*gEouqV| zdytgMdFkUoHHQ16y=KRLU89pwNPXj9g3;4-A@aJi!x&6QVWego^&-EtOQjde3Z4L4 z@1Dh5(JDo5|G4m%%`Z9$WW5#DJ2|9qgqR~!G0%;4dk*k0UDms9Za)=7DkEP zHbl?0p2P1ZdoT6%QVzWhaX?)MA>nLBHxZfSI9=|LhRMZT8%1JPN;C!SL!6tO-$@M_ zX07yx-j*^3OLoleO!@$_>3MxgS1sc-UuWZ=2KkN;#!m|4=x=2?NtFJRB4F=;{)=l#HQXJ&Uv|Lau$fT3T_L+ z*tdi$HfIfmWE3yvWLg^=vb|;asj(9m`Kt^C!P}H^&$)u{uE7Zj=+eiwH?q}g#gp-$ zvK3EVBYw`djlg-h-;O?b+OMK6L)?0WA+kZa3R8J&#AfQ;q;F2Y;!n2o7UAjTm5oTl z?_K!wVh>v!nA`JdnQ|{GHYbICCQ<1UsKQmoXv=2$oAkR&@AVj7b6dO~{rf+>R)L!4 z479`0J|X4E$NX8gx(tU|!F@#KcF^gHm!u$X6(#OTm9M(u{Ic3{kGc6ruHQ&=36T>2 z*iDwoj%8Iv=;S*M@un;Nh6dl4itc1Q;JQe7J%o72$ehnBjr$N*_Iwl+ViOu?HlEw6^x>+7u{e8zr-+C+b2D-A zc7`Z#B8KDRP5)f(e*FWcS*~Xeyont!+)CcV;B!^|1hFB$(T0^#WUUaiWOy#R>#Rxs zpZLry>^WCof8_k)^ptN7D1&!BXK=a!L-7rQ(LGLAzfvWbl+O1L6**d|^WOcgI>n&8 zesh)M^UQD6X*w8h15Mm7zWF=1-RCv`Y- zo4@&1B;!?qRly*xae>xdwPJJxFPg-QLPiK#5SY$6H0iv6)f)#7H!38jQxjM9dro)1 z7tmDh_(T-t*k@54V$0HA3Ot%8y0D2 z3>@ub2V-`py78Uxvio2;e=hxY*C}d7gOXWyB}#Xc{xB|Sau~o;=iKJGQ*tSKFt-Fg zxU54b{bB*x(@XS`;Y1fBmzx7|(GU4P;14KtoA*h3_#$G3aO7am0R%vSVY88XK{bs0rygKO4v z#x6?1oD}pzYePI{jH|vHs}*H=QLWN;HJTJZuFoSOB4L_w$$b-)+5=_|X({RXjR@ zJO-pXK+cxs``q)KLCNP(GjN2^aRG17PMEExQ-QRe42y8hS8&C_)M|A+_O+=Okru@pdjH)5|M=(#`hW zV@O^jnu{GQs%8Ks0IyG&&96%jgFmi4G*XdE$)RMfg%TCU7=Gg1Zp$6%=!5IhSqVoo z<=v(@Sw2RmRG8b;H|WL!k(c?gFoytoN z@D9mOX+K56dAOgTKD`QQ1`Qhs7Pmt+K!pO38OBWRd;$M$DiB`ApG_%&jYvD@rscCR zua>sgB|`foi78SnxXNei#M&gf=MvkB5k%t1LZ7=pj`t7U&MDZ+_OdugHJefCjrY2! zjH)hU=P>y=Hnb>Du(Aw3wjbTGPi?z&Vd%>}Z{(tyI*blQ`j;<-$KoQQHgtFTZoLJC z72jA->i?O3o8Oy#sRHU5A~68kyg-10__i!)Xn-fx-t9Z6b)J~M9TAiYt?R6P7N*r$ za|plDk3a3-Bvn$+1yK=Ay{+PHw&~dw^A)dXnAVOQhS+|O6R2y-lC+jxJj!6~y*}`M z@dNq1TBa7UWCg@?|J@6#Rw40W)JV6(hPpDpswygOZn#=;GAWr-nFJx(8$2x}TB5lS?;n zly1IYJ`w*(>%pOatU8{dCqU8K-W=_|XA)!U3h9R;Jk_1sVuWMO-> zfAumJe54@uy6_-IRAX#<{=ZEWA>A?ug?vTu&bYA~qo#UGRg}BEZ`p9YhbU3-De0H} zbKIq4S5CZ10rO}cs-#ur9q#1(AvKclPmkvFei{+1-^+1e2nCGaP3)cO@#V^-^V>GU zwOu8CoiF!jbE$H>SE+2!t{KG=xF}lX(-=-pP~vR2vExwC?(}~T&cxCwTXy&53XBLv z{1#OrDi@x&ft&kH`%j88yogky=3Xi-|B>pvOl{jjg3h)%-tbLKyatbD=rPqjyiHlM z77s6EG2b31OO`q2Y~S4xA{B#^o%dC>zT8Tg88}4S;hRjK9V$6aUS;>xgtr5@7bc*l zxba*_+2BPjDpjBP-nJC!>6aXJ&@tJ|UMp5Rs%a~0jvQz)rXWgg?>qPurh7pnM(yHl zsluAg^h~2h`dCT$y8yWK<+r%nP!+4SZrb+V(aU5PAtk?p=hatRHani6dk_M?^lm@487AB7y+0dE zHTugJuBJJI*jnAt#HfR4G-9DijqsJ48b2)6(KcQ&Vo9hQIag}-+iR;6H(bm^eu3ax zYOK%O%?gtmX?ob-nRNdB`3CI1%MjaG)Uonj`>xkDHyu&Gv!oc(g@82jtEQUHt$U|1 zL*!oC#ifMWUfKDz3U|S=$u~s<5|fcl-?t9mKJo7@Unh!NwRcQD#)T7K zz(;Ecz5;v zK3Km{>@4uSG`oq9Q?kl_5PQzEwydx@c>lPL|f26ezKs2 zmQjPH$!OF>is%-7sg7`nepLHTEY3l%CVeT zrq45ZOmrXpX}mwFYFISyy0bm-eZd)E=UmgY&&}S|8kXyI2tl4R{eY+0wz|&JIS^~y zJ5mV5{Qje=bfbo%F@a7khr|De97{HR$4uTieckQ;R;+b;&qrDAJ1_2HIA`q(2h1`Ir$oW zwMi(^>$#M;oNZfWo}9lU=)rXWyB2YW5aTc8_!4kBy2$QGT~&o4X7Mau7Als00(;tt zfury#W+L^4ZsDkcTn>+~8EhKk+R?9r6G9iaTkx8zf~CxJTp&mDv*uvbQhu=ROgfwQ z-QtuKWCk{fw8U|L*UC_s&1)|LSfxZPwdiwQ5 zp+Z=lkqwXx5hj38Jd>D%cRw%c|L%~LO7wC|o{h)(}Fk27Ygr15H8vX<1 zC{-ARLNbsMbVhlX;E1j50&u6ZOR>VTUlU+gPdSF>eUS#l0LQW z?9|()7xtu3Rf-2_O5?;D>>7%1Tmo;Ls+ zX=@t!muCg~&Q>d*6TEE&t!&H>;#N@|N@_61+=3s5`6u$-rhK6ff|5CHJtVR`Yt7G8 zQDoHOcbADB+=;RMQMTrB;b<65by-g^R>$wtl)A}>PouC+VD70y6yBj(CFU{xFMV=y zpLW#!^Tmw7WD8a06cM-JP+!^kM%3Utb==?NJKFFp#G=}Kr=aXYQd};Y-5PPm_XNg@ zTb}ST?NR+A*2Z>g?y9?*+}u>5$N(CV9cq-V?W@_{L&$+p&`*E35^}WpmBQrDm_d{+ zwB6uf-|_%oli+d0ENa8h{Uxq#`ZT6{hS3p9BsmQ_KBrYWz~Olo(t$o_#g$I1SNxp2 zD%C+43Q3~C94zke-I#+t_NiM~?n2vo!YJ4p--PK%ut{S@`v})Q#E1o9xL@OPi^(Ha z_l*rL@fbxLK09#VyN}TE@)5TV7Z8*k-FPp2gh;_kNG~DgnugkTYBnhrK%c@nKgY!7 z3+RxMTpdkXxLUwmJP8_SBk|^A#YM#?6dnwFLD&hOsKr=nlwtO(Yqtxl5H#nh;tW$@ zFRy(nM)xQ$Lt>SmMZBK*N0!9a`n`k>RWqK5quW|xfuyJ8>^4Idi*p&1XtamW0dF;# zi*oqpWzu3r^Ku_Z0C7X^6=DiiureUS{P6g8MH&Nfth< zBq9Q7pNWo2$8MSR&+S*t5+83r>=gA|@aFiI74q%9yHU>dRD&jQ5Pt6)Pw#jn^?LeP zd-l#Axm!&U7(3{CZfOMq_>eSQz)H<3xxAfc)K{`x?lW78otynAQ7MX|*M_aZr|Ewb zUkt!xIlrKOFs_4WHMM$s1z6id{mz%(74!BdB0<0@=NH@G#AY$S^8fsUogP|$iu2RH zts-9I3Od9CiUKTzkoBU{eKuvqP5#SDbSCyRoR0FRmm49B9H!jH^j3ee&C0>;cXE4L zKM8(O=(SM!#tBIzmk7{kfmK?uBc12R(hs(Q|Cuu_s^dnx^waZ_0+hCpuX86+@%g6_44f*}}8QO!RgA!Uj{#$d6Iy z?+X$xC1kQ+Gz3|Yyg*k8(G?&9j1&&oirSZ)fPKSxqR0KBHOnggpFxL?d`lFxbC2xm zb|%2gSLUY4^d;WDImluf`jdn7Ykr~m+Xr8a5*?S&;7*l~e`8qLvM40|tYT{c#^Su1 zw#WrHQ~xh_cg00;pJeR7A$`KNdS9k6vd$l5R|4mDb=W%Q(g`KiqqKMa#*Oo8F5e@C zw?jpPA>&r+*GIB>ia%dcLsn}`=Uv%jcEi)O+I2+MB#}t`dH1g=6RH&-CA1H8AE2~f z2mivI0@F&pBY3D9K&1&$-0k_`#Dy{GF_VRND_U=L-?C^l1(XPJIC8SJpt&IQ#hyo8 zo;X_=JX%e7)ObO<`RsNA+)n0J`rw7K3pjk?RHbZRuL0T=ekVBI#tM}aR}5hUT{m(o zH^8kDTjQKiyJ?x7RL-8ECH54wCo9c=9z~iLS3_WEljUg^+9_o9){?keL+O#rJrb+T zwRk`kJ|P^Retss;Zd}9Uutm8eYZ5#RDqVv}v#D~QfBrMGKYjQ*do=v?OUt9%6+8On zFIk{*MaeESjM_x6E+j>M89^UgQbtqqUjW00tx>A3o=TSu<#BM{%E4i|+FOYsL{XMl z%rz4RKco+wT_C)OA6Dy`t~j6KP1+~u|9taNb`a_OCXn8;i)8HtWOMR;{iQ+9dxDj( zh~y(M>uv-R0~U+rVUJ`jAf(#P9_O(yPAw?S5k)fw)c^)BlYI*5mK%CC_5_>hn$2XqCdol!jNAjv4!{YEqC90OyEHcL|)_D{q5LsP#K_a zwo;VIRil8-cG?N*-Tdd+g;ww-XKgQpxR$KZlBL)1$&3_EM47_;83eE^O2NmqyHnOV z$U;yd8j z4i=%d@dD>IZy`32J*3|bTmIsKr}x8Iko|=a`XN_gqTX5iYv1=$-E`vMBbazlTFW2+ z0;=m&;$|~ajOV`>egybaz(yLM^)^^MNCS{?-lc6|e60dq1-L!|UEFSjZfSv3%_^fG z8?@K);8&W0h|ODZ&1bh=-UbFuv9@pNpsn!RPmjP(86fe+U83I%*w1qP>)b}qAM7wp z`1=1!+3-J^IqtLCZ%_DSJoS73 zvF;{Rti~}t2cO#QsEhEw0+~hO7%*ZNmBVskF>YUpF+0I^&hp-z$TS)5{H`aXY`Mn@ zT$}j-xCxX1&vIak3Eu8`?!S3n{OAyTT;}-UPF8CkaXJcK;x>4?7UOt!-VSsC zQy~qIv1_>EVkOwH2joG>Fl-X9lLdklSoxqe3xgSRtIafSfu>9A-yy~R4m_}+|HW<3 z?IavwKZ{`~l>XN{^k34E8a2Rotg4q*{`F}V;etVS^VjETH$4S`p%7PEjU@og-gcn% z??4;ZU5JQhuF_q*2*S|IVt_4p1#Zg!2ut{ZzduL{Bi`DtQG}Ej`LCqX+{H#1I~UwS zfTfh}xYpxT2PPjB{#hP}fx%%#pUkWyJ}SY#C3$64XJO&>IjxwC(lgZO&Ue6Bm(TfQ zE?R)fP!WVM_=>8RdkXK0@zJqV6-5x*^bl=7GuZrXRWl%C+_wjV2;I?_Zm>ESMJ7?L9WwZB@hcKt?K5>IcuB{aiw+A^yYs&pu(x`5Kbz6^A7Vs-l&3uy<~>X zJ;gBW^|W$ugakr!Tj!3m05#JRgoPSiHemL*9_cpy zD=La^VY+^3o5WFqT_m~OQ`rE!?fJ(Nl%i;6o246lF)>-je1PNg;u0F?gr)m)#C8uJyc3*8Vi=oLcdR%RNf?u-oNJ z66I1xgIBrR=^hK?`*{AihUE#7dYLx;pD3uM+V#sX1i)W<)2BbRGC<069F9^Wqm4l71 z`gAlXF4vDaib;n6=f=*D_cI|eFpEm%An}T78T(U0RA6TzuRFeE#T7F}{p;}7s>#q8 zF6saxC(%xe49e)`_*xS-sE{DLSou2j6|~>tZ|&LqXL;F>F)OO3Qzp(Dg#Is}3;+Qz zPl&nEgPIA6J+}+?#=PYZe@GodU^6J3FC071L3@aIM^xpkCw>3)Lu)sY*?p-)zW~Wr zxr9n~uMlLF;vgn{k{~^|9w>MPiZOs9f^EE~Sc+|!Ra27>gFY5O8P{{cvcGIwwLVle0+N&4d}sd+z5S_gY^`N5K~Nd0FYy=gSFidw%C$&L4KXLMSY}7(rFb zpb>qev*K+K)-nR&pZSe{%!~~)PGYAkBL9IE29Gp*8Le-WX^)?)vQ53AD}iddu)jmM z4Cp7ADNWpgM2w4mbjqIRXu~?RTMJ^Pl?^!08wY}F&pXe%-E`_LBz6t>b)1 z*n@}9f?e(bWW6P=*e{;@d|N7FF3u?%qD%wk78QUr>5&2HPJGkT+|N(;T%0%L`GdJ+ zdqBu3ZsvqqBjz;<7BtT;!SoxMHqpVgUo^kJ3kGG2v){fnvP8gC`J=lBBPU;ED9?dj zp>cA-yIX#QXyy4vlT-41)OP!9&r=o_AEKL-cR#OD5I3m!DO*Nb(2iTWIJ)p8ZYY9C z9KXaD)o%g~)3DdcZWHkXl-%J=L`2T_i5q{PIk?Js|0?tJ_QN=HoHh8mI-W)(rY(CU z=Tk>r7FCK3%qABIJ1g>YR|Tzkqt+@4rz)x$^Tg5d?w5{j{+3RnZxuK=&N^vcxuoB+ z{g}?e!-DYiUy{uDUs^=6CmZA&YDBF75HCr5;7s5cc{1(PD68jn_0B5vYBdzBZxm)1 z;oQlnI2AlZ{2RytOGR4Yn9q2w_UOHBUB3tt&|LFl3`HOD&xi8!&D!ko_4-0q7!BT4yu>r4{z4Wg)D|V}6EgKkJT&V0{rLr~3(z8`>ScwNJ#_^6_6e0w9?2u@#oQBZ#T=u`b zjd&^Hk>Z8Ycc5cscA1?GPF;{$v#Y2dd$IpZYjsK6dr`S7KUZYdqz*5>W0;IHK--ur zC?MU3Rgv>M4!GXh$pyu8BEH`SgGiT5)Gl9nQy|zcvP;#*530s)cXV zc$SJg2yXwTYbLio`itcy%DR7K{{~p`irnWxGY!);T25N6u5cnYCJ`GrbY9g^{pA zT3DvIlQ1IMwh?5PQk_+Ds;}EYJ#KOf`;w5jePr{-x^L%^pqW2$vQq~#O?M2=zxhF>U4lA z@{u$+U7^cYq4wIj1;ql^5^I9}w7_|+ZUMi6Z~&IIV)((yNzLh;(pB+Ph`dT#;ii(I zmV>K!YTNUn?Pa07ke>w`X8x5rD;U5**$b*f#}Azg`4Qlg1kRz_(pm)=Ng+eZUFnqW z^DBB++G|KA13b*H$KVd&*bczU9d)-Z*hFsX{7?SPJJJOx2Q(uA!L_nC;P&kSkrhzF z0ulKP<%-VTN0sR|-ode;-p!x(n>--A-b8w9#Xj(`Zo>f3tD>lKUuB>DlZrvOw(DYE z7yt$*JMDq6pmg8{HVKEJ_k$&~7>~#k3{8FVXLk5xo1p>8KNULB*ZL!Z;8bSz0C0{_ z7ee+X8>a)2TCljRi!QW^5&n~7t^2FF^kludkS+Kt0~j zbjP+J3DRl|wOhaBfl?S-79E$FCH{NA zKC62rb9-zH7mokB6BUwrNMMs?qeYY)q>io}D>1Y9wl0qVcgTu}8ev4VrQN2@Z)z-( zG}7t&>nk-IPNHn5oy9hxC(UQCh|pdj#AwmqQcv+U<=RcS6Mn$le%6sLwvneK=_tkX zchnd1(<5U3X4E-}>9R zF(Eh`_(Dje#rEXy6T<;`sS%?Z+U%;LKDmaOO>>kCs&C8NN?$#**m-F&Vgo&I9(7(k zRth&_GQ8x|JeJ_!bhp|1wLVw&rjq;;%WwX@P#(gxr(Y4}x40duF!me=PSc&_^M7u9 z%L78*PoS^=@{EA~8f#macAH-{!Qzqh6Y0YMf6L~|531+bn>Zw@3Q%pB=h1$lU$ZJ} z+z5dQr7JqxqGJQUDB-%D$RW%vgZ&K?;R|tK1XFtX{OCE`pqRFdt`)d_sjyP-93dCU zeozL;?8>Eh*bQo_F!MQ*@bH#F6Z(5xB~@8Tbq)2(^$0DC&$Q$5`7{@^zX_7;tXYWU zlNS&fOD})-Vd#r(vxssUqK?fs3C}fBF#!btc!= z62Z}ZAXk zTlN6Pb!ZR;^G0pRNsrsZ!05t;!bY{LCS!rly5=9fCsi*Ht{=$vp()YtVIVM1%pk^d zH@>hKY%7>?Q)04X>Lzk|g0ix*u=NjSkhK1>TuP2|#~o$@l-?8>DG#&Q;MAi)%WPN@ zhe}PM5u7*>Pz`3nJi%4isg}IWLiQF0u;TAicm&<-H;td&#R7WS-*q1lV|QlQnKi>y zFCQPfgo~$xzfEu-hOIvyKxK={l5lK(-Rud3K*(Y)>VOZ9W&)_nO>k^gxnxojnc;ij zB*Y9c&|mFK@F6oL+~(YD2jIO6VR!JwxdK4mv%@cspVrm#0P%%__-P<@Q5Q~X?!y@W z0IHe2WjW~2nMGW_@yB2*Lj|!jO5)&%dfmtxD5fCkT`=Y~XX@==FlcX_{uUua^Kk3Q zf@C8QN%*_P0QzR}MH7T}`bL|JOlsQ1$t_;7-h25S{9(BQJs0;AiS-n6Srt&zF@yH~ zDML`)gG^dWL)(3}gyi*xoNB2AUC48CrLe|c-WHtNOxw{3sf)RI%P45NaLpD#c~G)@ z6D*Dh(>K$gMtkVHCA+m40X5K@t*Hh}Ro1^?rhxsa&^zh5MNBiYwmPj^u7mQ9^n#2idIqCKMeRpIbUgKnn9 zdvHZ(o`weC(6blp0I@E!o9%24x8D9G9SJi1kmIGKpIwJbcOC=xZtZT(92e;8-yfjX zh~(6-K%h{05xy7!GbM0V_pVsLK>^Sp)bz+zo3_r^bskpuWt)w7}I z9Sv}=p<{7MGjhmt<@F=g-(G%GX90P~K4h$6S)f)Px@!yUIbu5g;g^ZoYWQ$tzNw9~ z9CQQD0&f9$a;Ms)p~(&na|lud$RK_f_un=+W3X_~sEHskZ{9P7_=pteW&A%H@;;&h zXL1xi68X6W8caCmG?q$ps#)yKA9kPGsKDgZQ^EI4v0qT&z-QCxD&FkWIl!VGVOScX zj%*c7w(i1ReQbOc{K`kVx5IlVvflBkF&p9e<5PMbF6PCp>+SBR@%$@sUt#~ywQ4eh zH+$afZ*Nf^_(@xEFuOJO(TlAx-GYvptv4kE2a z7hv8CsdCHjkaV|Ig|O3MmYD6pb*XG*!y^u`8y6hIkJtOgj0h#~l4mVRVy9>|p zR1!!U>vNFNKaV^vNHzI@_iZ?<=D%!e^Zah`Sn&09?|=Kh>p#r?zlZ)u_~ifo*8hD4 z{-0a{8f$iN4H@6sTQh<>Tblkn;H*OF^Why~x1Tw0X*_T}r{>0%n6nYLpE+&0eP&Pg z#e^Nd*NCcJ6~V=p`g>-tpgS4oivXafdR~l@Pw*RHx&kSL)%| z;V+fo3Srzj;BOoR2frB31=L|gvbv2qR|}fZ^6-n3vp|JH*qX*1tX*$2J*$K@6JC`z zJRFhqmWNurz3Ei2&J<)}plyj?L6V~u|6{d56si!r2U0%oyy`NADh{_Xs2GQ@V=pr8G7=M-ocpwAj1mGmx+}73>KjRS! z@*k7~&>)w2<)45Z>$tN3Bjng|C?A*}CLg>I7xHs*nAC_xM#Z*_HW-1KuqwpbuJR50 z0{O#=M6PBn^H%f6I~o`yz9ZZ^eCS)))I!PNZ%|JNcP2s8k^MG+Y^c}rOz`Cbv8deK z+}`n=oSYSpG3$4O1!B`{$n2aPXR+e2wYkgV%`qX#Z+Sh1HcxQOZvg76CRQKgw>BlI z_%<^eUgv|L`X%ITaY4?w%Kk29l_aaX(86?H0Y-CHlzq(P5iF~nksna0Ta{P-z-x2 z?WEfoXMr~8-LhTd%ripW$-n&kt~14v95v{-nUnEc&OIZ+>Vb#-|BN`r<)dt1&r%(8 zYEHh1WpqEN6zYM4XdCZPEcX^jZDFQb+z6i*@24Frtb}@|kpRl2HU7O#&ZKz{Qi6}K z@8=S%LDp>ACAGBKRqS+kvLDlb;~eaWsxnn+kcPCX!R3v+3Ge=DuiGh4i$qrQ%)BG3 zD+vyv1g_*Ck89s*cqM7YSRHS2TRX+myuor=h+2Ktp5v*-%&tRL5FjuD0jT$@GFf0$L@lr{_ z*%Jqr?;`sCg#U1w4CGz6^`Z`y;+(+T{qictcIitg& zBnoJ1d-poi#xeH1P$#ClDCu9m*voUYU^@tK5QZYh{~#QCV`t{AALBsyIE zyC4@RadY6~d+Lu-Lu6_#F-cn_k>r4NDl;tyO67;Z6v}Qe=`6`zR=+tr(+KJsis<+lpaH{A^r=g*PV%iDM{5}K6h`YOrfBP+RZ<{Ys2+i6Z?anyLwzDVPjzbn3gHD z$~`OjZZLN9;&FXH5MNuG`G7e{GjelB+tTIa`zSl#K!%eC)UJhN#!La08d)!*Xj zksa-3y7N|jlqVIptV^)SF!eF7xo#Q67^GE8s4xQy5%^wzGyf+HG=2$(?Ev{n%ZeG^ z<(c<5`Go$Eq!l(LcR=eJio57!iuA*_S-7iNgGJ=x)jOb-_>zw}Yp=3X@ND_pdmQKX z^?&FTDgkb@3vaE?FCGm&%MTfeA;mg5x+T0*w0`d7BLwK%SKlMjMq@l4DUmNnwJ(BiHW@6ki8$Jy^a+#>+sHCc9T``5K6v|vYX1`LrKsIzon#*?D z;B`LvzEo^L)s`uxQTn0-+s~>anno%S8R8x-z}eD_nD|ORprJRd=q)BUk4S6lpGpx2 z=0Wn$vn++xadjI0iBhcVl*cE*JH=wqnojzh)$|CPFOCv3!|hH;?6WZ9qo+mmw_+%* zU#b(2jaMTo;8#s=$9#q*H`zKq;DfLZXK3)mdACRfpHtba$6~v1Jtvg9Q zxfr=BJO0>zn$$jM>$FC&l!mkJOsuL~)&IWZR(WdJ5np|s=hsKVI$$_ZFPD$3L?{1a1?m60f0! zXis+-CEc>u8b7%p$#-{oV68nPInb=hA!wAlMJ>7NYgcbs_s2V%=JW4sDA^#I_MEXW zOB_A6e$7rlTO?O{cN>QN5$Ko5JjLHkBM~cIcSP@GrC#SZ>ov5_#Ab(tpRg<6g{n*m zQHd=cHhno&Lh1ii0zY`$O^<$s0GU zL<|osZMHD9@cDi3`9+oAa0~*#Znbx3Cx_WJuZCwz-CNJ^_>_lo{(H&dV^UmfsRtBG zQtZoikB3(sn#D0X=|Qcpeq@yrH^%eNE4)ISEnn>7vZr>2{oMHU?LUd%I>VFJ7G`$r zxV+(=Zpncu+KSP4#*?jW=Ta6@#uIrk)4ymr~+?X;vSkTn% zZtiejWoGJ@lB6ThMPD6Bws62dIZ{%zCB-VHDq<`vwd@*r4e~NfXJ2FzHZBBgdbeDOv z(mFXp6IimBn-F@#}DyzU|&P0z4`fqi7`z=0eb#bL(V*kKZKO zJ_&EM(C1=Fc-Q$`yjw2}Kl!k&wQOk$1n^8|gmgaXc8nFtu4JX#fO1r|{j!j>n;c4< zV1^G#{iLc^t^G>|DB7-YF30zWxK>^p{Aawtk#iBwesDa4El!14ai;Ay^kCC%&KMaP zS;uB;O@5vyYg%i^5FsZEQ;ddv$~eD0WnAJVVKH^Z)5}c+vOP3GR><&(E+~{Z997-d{FOx zy?46kJBc!UEr8xx=7#he3U&{M12u#c?7@wrE;UEo&(kt6c*7ywL6P5nQQra+d^*?f z@K4sXRG{jL_i2X81&pok+7ixUsk&lUw!Hd;-3K`Fo`GNG;^83*N6n?&28jn2bfivs z>z7co%WdNkZifqW3=EKETwDI|=`(nF3%Q>Rb|Pq`Ec6QBzWXnKl}mll9{TJsvA|t6 zpE9Zt17~5_zY7RtxK`QDT`>4B!(dc3LvSn1wF)>awZVC)qC%;xtc-q|cauG4_i3`D zCf4c6Q0N=+P3r9r`NRz*d|$HzA+ z6-YiH@24VsL%f3>WjGqmKfiQUlpu}ibi~%c6%NMM8i2~sl5Bqt3e0c zHpQ5>@p$)2HPp%&Yw|_ao`*`~-U8zbYd=uQpxI1emWY$h0)NIwjJuqcmzQ6h#EQaf z4RWzTVs9)F3nx!=WLHz>O$*ds$xjQ@)LSc6IDwK3-u{)j{=N{MT5odX&Rk89EXd*@8ySP1)cA`+3Cn-eK%fiX=W*p}d1VkIrcuvxm1!ml40 zi+t_VhNsx_Dwq0D@;X;`1$iW|b*zo{tuRPzF22fRh);!uEkU6_xc^`h$$$ zL`Z6cSetpzVdbo-e%wg$vtN&---@+y`uiR5@zBMN!cEVuE&H@2jZ^0Cm)h$SI%9kE z_V+w%HbKryE)iHv9P5r?=&rTO-wt@YsW(HXag|*u{l+m+Fh=y)o&*3bt6w8C(Y83w?nm>G&UGP$-SP z*I^RRk?x{#>{}WVx!^dDRZ!xb^wzt7z2c1pd7yY>-Cigp+NINZY{;BQloVZ_D7t-+{nX>b6_eilIbH>x!W`bn27=*@GjLPY{soXs8RL}(DA@oP}58ON-> zQ_5e?r`xrY_T8HcdN(OmfM|$tJygK0^Y0Z8^^WTyK(nSpPC`85=uXKPZX50{*M+u^OqOi; ziC-KO1B%YvKA`|AqNZX`+ON?5kM^$hFUf2RTXvj0lc{BgDK$@JWs{f5CM&$;q^NcB zHf83O%FMjr9S8-Pa>`q3T1S3PV42-ZEH9WQ(u@}xnLtg&I}s@|hK?v80(ur7&L42T zpZ)Ru@$UEc?su=n+RwArv*u0fUrjH@I^V9yG5CEON!5q@%EgmI#1hwda!T(q7>R@K z}Y^#jX)% zdlOb^$3%_H?;TolyYPJn&reW%6}Ia?Q#%8`x2za^Xv_JSHiq06Kza8^J@J_P3(Knk zyMrXn81~cQ>+RQxYDM+FSEDuO9gtJ=wB8MGttUf9R7AJMPn}H=eA2846|Cm$VG6?O z()>K%;1Y>l*_NSoOqyjVB7DB()Z&jsq_dXCA_Nad5GM^TxHosb{W^^FMYNGr_3cvG zbdxc&4)Z}a(wHPyYD1n%Gayl^nsbXa9zV4CH$(yIC28H})K%N^w}VERoVdkfQ08h) zQvW;|^-`{24mv^747CsQ99&sbQweyVA6CU$jm_S45(6 zcSEs+fK&ZsjPdanQ!5{(uKVVw-rnj*?2Gh{y_8FE0umYgED2?0L zRvWqs;Osd$`Dl4Xt0hGd;wpM(or_dNdzi(Iw3IG8zRyk&9fI3hV4+Oe<68qAU7g!V zYKjO6A5-?%U){}?nTUh+)hc4-V+xwZD|r;V!zXU!@wn7zqL_ME{Bv(qV@+m)x)q(1 zkgPndX)Y0~J}S-(DPAPa~Y}nc-0eioF>VGdS5G9=4rEQ z(2aLju@P96ahyJd^AJ4gp8QV_-Y@spyY}N4QVZy@kcmE*qqovA1#rL)-;=wy+ZRQV z1KYA#cpa`~2UhSZjoSoD9axzQ9u@fdzKy1zx?jD?6#Bx3@*C7AO55{tIkkU4*pfk$ z;-+`qI>wBIP9K;HD^O$3ak}FhRi4C*bT&;1G*cCX z4vD&83f!BFAz^ZILC0^PRaO)Aq0{J(gAY~w)Bn&}nbb4z^ZJ}@)^Vv0KZ^`0Q$0T< zriOv|qg>{q435ney-v&g#Sf#JZ4vpGwxhp*i)N0-@Vu4#p}0LZ!VDGRg=&ZY_)e!j z2)_7vqCub-yh8th>apQr#2JSy{;coDYNC2X_3S<`$S|nu6i8E`?#u>1^ST&{iK2Oe z>-!12ELEW4y@fsXI6kBd+QWA?F(e#vJWFt}Xm3#zMcf4WsP>%NB`T=o+)JB&9$e? zf0knK4fdN*LO=ndT;D^JT0dFWind!6C*m7w-^)fu10r8nIFT+9SU+R*4>0=xc6^X& z!QL%CP+z#f@d5U#wpG9mFrTHS0l6w?c(w!12H!2bLX2(hOc|XjID1HU68>%Z$+dNc z3!3$3_{I!p+>POZ2AT)?E+c9qs^@p*2y#;w&Tm1T_0zWPGYZAP%CiyZLV?wAo25T3 z^c1ySaGXOl$n?L-q%R9JUw=*CY2EHBM5yR6~VzY7mM}T11AB-=#P({?-6*} zEcRm50?8---B$nLWV*)jTRY5iK^AA9ou+xiagS(n_Cnnm+fcq_*e~o(Rw99QDl9{# zGKKd>t;c(rODH9c>$)S@hX>OD2wF3RIG^jRM|211543?c=MOo(a5>CfCpE5$H)lKo zSLu49_Sv%xpY!Qd8ve}~ZhZ-NJ{&GHSdc6@FVZn~ z^s)cl3w1+%>^~`ObjJ(eV`QPb$TWk448h-GQjMy7KL~a zb;#D#_uM#vH>Y4?wx*aN$r_V!)EDarbu#)wLuyv&lka^Yd#6HUU!2!!k9~FK9YjxX z$+7(1`B!TdtGZ{8%#ee3uF|tQg{3|{G*f=z`08m$pke)T5$c>RK}T}B7j4OS)}b+?beoPHK?^m#5jyyufMHiNQdg(a?pu(EBL<- z#O`wfw=ci;<58|qN1-qi9=97zMWy=f4$@~XURJJW<r`j$qc{Au|c z!PwTl!*zGXuB#V{zi=oHwUIS@?!@Re;zy5eVPVNZ>xTC`>)iA&he3o#r_+&wclW-e zI-8Ag4aN_6YW?p?L(p$>_yzZUAa9GiOngw_Ju%mx2Ir0C``3G0QvY2`*s4R5Gqm>j zZfx#Zbc}m8foyZ6v(S0U$~!116^jirehQ-eg~0(5_ptp?)OXYVAt7+SPi@pr_qLmuIU<&nrI`z6rGr zgc*M&n4#o1X4Z(`)*w(NI;!7x0nk@^;})thNng(|Y-7`s=J8%HbkTpguCuVdy0OC@jzcis zt~G%*g7MvIU@Uawh8~sg((XGP&v@UN~R(X>QU8@b_+jaLIL-~2qkDk5yAD{Qh AQ~&?~ literal 0 HcmV?d00001 diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs index d0f94949..40993e00 100644 --- a/Tests/OcrTests.cs +++ b/Tests/OcrTests.cs @@ -155,6 +155,73 @@ 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; + } + } + [WpfFact] public async Task ReadQrCode() { @@ -362,4 +429,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/Tests.csproj b/Tests/Tests.csproj index 3a93deb7..97ada3c4 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -47,6 +47,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest From b2fac53b7d1505c59e121ef4da3b82e484c35bae Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Apr 2026 23:40:01 -0500 Subject: [PATCH 03/81] Add ParagraphDetection to settings diagnostics output Added the ParagraphDetection property to SettingsInfoModel and included it in DiagnosticsUtilities. This enables diagnostics to report the state of paragraph detection in application settings. --- Text-Grab/Utilities/DiagnosticsUtilities.cs | 2 ++ 1 file changed, 2 insertions(+) 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 From 2963cbc0dd1033a10045654e940517e50a5c7e25 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Apr 2026 23:40:23 -0500 Subject: [PATCH 04/81] Add paragraph detection for WinAI OCR space-joining langs Implemented paragraph detection in OcrUtilities for space-joining languages using Windows AI OCR. Lines are now grouped into paragraphs based on bounding box geometry, joining wrapped lines with spaces and separating paragraphs with newlines. Added GetTextFromOcrResult, IsWrappedLine, and IsWrappedParagraph helpers. Refactored BuildTextFromOcrLines to use the new paragraph-aware logic. --- Text-Grab/Utilities/OcrUtilities.cs | 93 +++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 55864e52..eed11f63 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -369,6 +369,13 @@ public static async Task> GetTextFromRandomAccessStream(IRandomA public static async Task> GetTextFromWinAiAsync(Bitmap bitmap, WindowsAiLang language) { + if (DefaultSettings.ParagraphDetection && 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"; @@ -427,25 +434,91 @@ 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 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 (DefaultSettings.ParagraphDetection && isSpaceJoiningOCRLang && lines.Length > 0) + { + text.Append(lines[0].Text); + for (int i = 1; i < lines.Length; i++) + { + if (IsWrappedLine(lines[i - 1], lines[i])) + text.Append(' '); + else + text.AppendLine(); + text.Append(lines[i].Text); + } + } + else + { + foreach (IOcrLine ocrLine in lines) + ocrLine.GetTextFromOcrLine(isSpaceJoiningOCRLang, text); + } if (language.IsRightToLeft()) text.ReverseWordsForRightToLeft(); - OcrOutput paragraphsOutput = new() - { - Kind = OcrOutputKind.Paragraph, - RawOutput = text.ToString(), - Language = language, - SourceBitmap = scaledBitmap, - }; - return paragraphsOutput; + return text.ToString(); + } + + /// + /// 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) From 60fbf824bf98152a1aa69138c150742ce72da486 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Apr 2026 23:40:36 -0500 Subject: [PATCH 05/81] Improve paragraph detection in GrabFrame OCR output Replaced the previous word-joining logic with a new method, AppendWordBordersWithParagraphDetection, which sorts word borders and inserts spaces or newlines based on paragraph detection. This enhances the accuracy of reconstructed text, especially for line and paragraph breaks, using OcrUtilities.IsWrappedParagraph and related settings. --- Text-Grab/Views/GrabFrame.xaml.cs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a19092cc..f385a69b 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -3469,7 +3469,7 @@ private void UpdateFrameText() if (selectedWbs.Length > 0) stringBuilder.AppendJoin(Environment.NewLine, selectedWbs); else - stringBuilder.AppendJoin(Environment.NewLine, [.. wordBorders.Select(w => w.Word)]); + AppendWordBordersWithParagraphDetection(stringBuilder); } FrameText = stringBuilder.ToString(); @@ -3486,6 +3486,27 @@ private void UpdateFrameText() 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 (DefaultSettings.ParagraphDetection + && isSpaceJoining + && OcrUtilities.IsWrappedParagraph(prev.Top, prev.Height, curr.Top, curr.Height)) + sb.Append(' '); + else + sb.AppendLine(); + sb.Append(curr.Word); + } + } + private void Window_Closed(object? sender, EventArgs e) { SetGrabFrameUserSettings(); From e3fccf8145d9f757a34b0ef636b55e31cc958351 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:14 -0500 Subject: [PATCH 06/81] Add table document model and spreadsheet undo/redo support Introduce EditTextTableDocument for structured text editing, with parsing, serialization, and manipulation of tabular data (plain, CSV, TSV, XML). Add EtwEditorMode and EditTextTableDocumentJson to HistoryInfo for editor state tracking. Implement SpreadsheetUndoHistory for undo/redo of spreadsheet/table edits. --- Text-Grab/Models/EditTextTableDocument.cs | 827 +++++++++++++++++++++ Text-Grab/Models/HistoryInfo.cs | 9 +- Text-Grab/Models/SpreadsheetUndoHistory.cs | 69 ++ 3 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 Text-Grab/Models/EditTextTableDocument.cs create mode 100644 Text-Grab/Models/SpreadsheetUndoHistory.cs diff --git a/Text-Grab/Models/EditTextTableDocument.cs b/Text-Grab/Models/EditTextTableDocument.cs new file mode 100644 index 00000000..696dd599 --- /dev/null +++ b/Text-Grab/Models/EditTextTableDocument.cs @@ -0,0 +1,827 @@ +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 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 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); + 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); + + 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; + + 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; + + 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; + + 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; + + 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 = []; + 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); + } + + 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]; + } + + 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); + } + + 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 double? NormalizeViewMetric(double? value) + { + if (!value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value) || value.Value <= 0) + return null; + + return value.Value; + } + + 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/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 69f3e52c..4f6acd26 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -44,9 +44,14 @@ public HistoryInfo() [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool UsedUiAutomation { get; set; } - public bool HasCalcPaneOpen { get; set; } = false; + public bool HasCalcPaneOpen { get; set; } = false; - public int CalcPaneWidth { get; set; } = 0; + public int CalcPaneWidth { get; set; } = 0; + + public EtwEditorMode EditorMode { get; set; } = EtwEditorMode.Text; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditTextTableDocumentJson { get; set; } [JsonIgnore] public ILanguage OcrLanguage 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; + } +} From 535ec663a6907128fd03a0bbe5aa7970411f5e3d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:37 -0500 Subject: [PATCH 07/81] Add markdown utilities and async file read support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IoUtilities methods for markdown/spreadsheet detection and editor mode selection - Use ReadToEndAsync in FileUtilities for async file reading - Add MarkdownDocumentUtilities for Markdown↔FlowDocument conversion, theming, and detection using Markdig --- Text-Grab/Utilities/FileUtilities.cs | 2 +- Text-Grab/Utilities/IoUtilities.cs | 32 + .../Utilities/MarkdownDocumentUtilities.cs | 832 ++++++++++++++++++ 3 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 Text-Grab/Utilities/MarkdownDocumentUtilities.cs diff --git a/Text-Grab/Utilities/FileUtilities.cs b/Text-Grab/Utilities/FileUtilities.cs index 97b84f15..73bae77d 100644 --- a/Text-Grab/Utilities/FileUtilities.cs +++ b/Text-Grab/Utilities/FileUtilities.cs @@ -137,7 +137,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/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index 748eb472..b05e90cc 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -4,12 +4,15 @@ 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 MarkdownExtensions = [".md", ".markdown"]; + public static readonly List SpreadsheetExtensions = [".csv", ".tsv", ".tab"]; public static bool IsImageFile(string path) { @@ -27,6 +30,35 @@ public static bool IsImageFileExtension(string extension) return ImageExtensions.Contains(extension.ToLowerInvariant()); } + 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 async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) { StringBuilder stringBuilder = new(); diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs new file mode 100644 index 00000000..d04152ed --- /dev/null +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -0,0 +1,832 @@ +using Markdig; +using Markdig.Extensions.Tables; +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 class MarkdownDocumentUtilities +{ + private static readonly Regex LiveBlockTriggerRegex = new( + @"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", + RegexOptions.Compiled); + private static readonly Regex LiveInlinePromotionRegex = new( + @"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", + RegexOptions.Compiled | RegexOptions.Multiline); + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAutoLinks() + .UsePipeTables() + .UseTaskLists() + .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(); + if (!string.IsNullOrWhiteSpace(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url)) + hyperlink.NavigateUri = new Uri(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl()! : linkInline.Url!, UriKind.RelativeOrAbsolute); + + 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().ToList(); + List headerCells = rows[0].Cells.Cast().Select(SerializeTableCell).ToList(); + + 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).ToList(); + 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(Environment.NewLine, "
", 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.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); +} From 89827bacb01cf4f5bbf778be1e4dacd2351c964a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:22:48 -0500 Subject: [PATCH 08/81] Replace SaveTextFile with SaveHistoryTextFileBlocking Switched from FileUtilities.SaveTextFile to SaveHistoryTextFileBlocking for saving history JSON files, delegating file writing to the new method. --- Text-Grab/Services/HistoryService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 20e81efb..30cde165 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -470,7 +470,7 @@ private static void WriteHistoryFiles(List history, string fileName try { - FileUtilities.SaveTextFile(historyAsJson, $"{fileName}.json", FileStorageKind.WithHistory); + SaveHistoryTextFileBlocking(historyAsJson, $"{fileName}.json"); } catch (Exception ex) { From 34b80c9cc85b5d291d0d1ec70c6213cde4f43d80 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:23:25 -0500 Subject: [PATCH 09/81] Add comprehensive unit tests for editor and markdown logic Introduce new test classes for EditTextTableDocument, EditTextWindow, MarkdownDocumentUtilities, SpreadsheetUndoHistory, and file I/O/history services. Tests cover table parsing/serialization, row/column/view metric operations, markdown round-tripping, undo/redo logic, file extension mode detection, and edit window state handling. Includes WPF and theory tests for markdown and history features. Minor import changes to support new tests. --- Tests/EditTextTableDocumentTests.cs | 173 ++++++++++++++++++++++++ Tests/EditTextWindowFileStateTests.cs | 68 ++++++++++ Tests/EditTextWindowSpreadsheetTests.cs | 36 +++++ Tests/FilesIoTests.cs | 13 ++ Tests/HistoryServiceTests.cs | 67 +++++++++ Tests/MarkdownDocumentUtilitiesTests.cs | 159 ++++++++++++++++++++++ Tests/SpreadsheetUndoHistoryTests.cs | 69 ++++++++++ 7 files changed, 585 insertions(+) create mode 100644 Tests/EditTextTableDocumentTests.cs create mode 100644 Tests/EditTextWindowFileStateTests.cs create mode 100644 Tests/EditTextWindowSpreadsheetTests.cs create mode 100644 Tests/MarkdownDocumentUtilitiesTests.cs create mode 100644 Tests/SpreadsheetUndoHistoryTests.cs diff --git a/Tests/EditTextTableDocumentTests.cs b/Tests/EditTextTableDocumentTests.cs new file mode 100644 index 00000000..3af23bef --- /dev/null +++ b/Tests/EditTextTableDocumentTests.cs @@ -0,0 +1,173 @@ +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); + + 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(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); + + 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]); + } + + [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.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)); + } +} 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..ce9d1971 --- /dev/null +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -0,0 +1,36 @@ +using System.Data; +using Text_Grab; + +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]); + } +} diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 18808438..6fbb5403 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -94,4 +94,17 @@ 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)); + } } diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs index c2965c80..28bf8b63 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; @@ -181,6 +182,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/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/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); + } +} From 8bc93d36f4759e8ca1aa19287da5f1c7ee78ba26 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:23:48 -0500 Subject: [PATCH 10/81] Expand allowed Bash patterns for pdm CLI commands Added "Bash(pdm *)" and "Bash(pdm api *)" to the allow list in settings.local.json, broadening permitted Bash command patterns for the pdm CLI alongside the existing "Bash(bin/pdm *)". No permissions were removed. --- .claude/settings.local.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 210267d5..80c27172 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,10 @@ "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(bin/pdm *)", + "Bash(pdm *)", + "Bash(pdm api *)", + "Read(//c/Users/josep/.claude/skills/pdm/**)" ], "deny": [] } From d06540f90f6afb2ecf9b1035a27efa3c2b7f2c8d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:24:13 -0500 Subject: [PATCH 11/81] Add spreadsheet and markdown editing modes to EditTextWindow Major refactor to support Raw Text, Spreadsheet, and Markdown modes in EditTextWindow. Added DataGrid-based spreadsheet editor with context menus, undo/redo, and row/column operations. Introduced Markdown editor with live preview and theming. Updated UI for mode switching, file open/save for new formats, and improved styles. Enhanced bottom bar, selection UI, and window lifecycle handling for all modes. --- Text-Grab/Styles/DataGridStyles.xaml | 24 +- Text-Grab/Styles/TextBoxStyles.xaml | 8 +- Text-Grab/Views/EditTextWindow.xaml | 218 ++- Text-Grab/Views/EditTextWindow.xaml.cs | 1789 +++++++++++++++++++++++- 4 files changed, 1934 insertions(+), 105 deletions(-) diff --git a/Text-Grab/Styles/DataGridStyles.xaml b/Text-Grab/Styles/DataGridStyles.xaml index 872e2b2d..264fc636 100644 --- a/Text-Grab/Styles/DataGridStyles.xaml +++ b/Text-Grab/Styles/DataGridStyles.xaml @@ -75,7 +75,7 @@ + + diff --git a/Text-Grab/Styles/TextBoxStyles.xaml b/Text-Grab/Styles/TextBoxStyles.xaml index 393d0e24..6b3fbd87 100644 --- a/Text-Grab/Styles/TextBoxStyles.xaml +++ b/Text-Grab/Styles/TextBoxStyles.xaml @@ -72,8 +72,8 @@ + 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}" /> diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 335f9357..4027e9f4 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -69,6 +69,36 @@ Header="Move Selection Down" InputGestureText="Alt + Down" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + Orientation="Horizontal"> + + + + - + + + + + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 55afd1a0..4a439cd1 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,6 +1,8 @@ -using Humanizer; +using Humanizer; using System; +using System.ComponentModel; using System.Collections.Generic; +using System.Data; using System.Diagnostics; using System.Globalization; using System.IO; @@ -13,11 +15,16 @@ 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; @@ -42,6 +49,12 @@ 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 OpenDocumentFilter = "Supported text documents (*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt)|*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt|Spreadsheet documents (*.csv;*.tsv;*.tab)|*.csv;*.tsv;*.tab|Markdown documents (*.md;*.markdown)|*.md;*.markdown|Text documents (*.txt)|*.txt|All files (*.*)|*.*"; + 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 +69,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 +98,33 @@ 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 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, + } #endregion Fields @@ -114,6 +155,8 @@ public EditTextWindow(HistoryInfo historyInfo) App.SetTheme(); PassedTextControl.Text = historyInfo.TextContent; + editorMode = historyInfo.EditorMode; + tableDocument = EditTextTableDocument.TryDeserialize(historyInfo.EditTextTableDocumentJson); historyId = historyInfo.ID; @@ -172,6 +215,7 @@ public static Dictionary GetRoutedCommands() {nameof(SplitAfterSelectionCmd), SplitAfterSelectionCmd}, {nameof(OcrPasteCommand), OcrPasteCommand}, {nameof(MakeQrCodeCmd), MakeQrCodeCmd}, + {nameof(TransposeTableCmd), TransposeTableCmd}, {nameof(WebSearchCmd), WebSearchCmd}, {nameof(DefaultWebSearchCmd), DefaultWebSearchCmd}, }; @@ -401,33 +445,1174 @@ private void SetCultureAndLanguageToDefault() Language = xmlDefaultLang; } - internal HistoryInfo AsHistoryItem() + private void ApplySpreadsheetDocumentChange(Action changeAction, int? focusRow = null, int? focusColumn = null) + { + 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), + 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().ToList(); + + 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) + { + if (SpreadsheetDataGrid.SelectedCells.Count == 0) + return; + + string selectionText = string.Join( + Environment.NewLine, + SpreadsheetDataGrid.SelectedCells + .GroupBy(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) + .OrderBy(group => group.Key) + .Select(group => string.Join( + "\t", + group.OrderBy(cell => cell.Column.DisplayIndex) + .Select(cell => ((cell.Item as DataRowView)?.Row[cell.Column.DisplayIndex] ?? string.Empty).ToString())))); + + TrySetClipboardText(selectionText); + } + + 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]; + + SpreadsheetDataGrid.ScrollIntoView(rowItem, column); + SpreadsheetDataGrid.SelectedCells.Clear(); + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + SpreadsheetDataGrid.Focus(); + + 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; + 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}]"), + 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.Enter || SpreadsheetDataGrid.CurrentCell.Column is null) + return; + + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column.DisplayIndex; + int lastLogicalRowIndex = (tableDocument?.RowCount ?? spreadsheetTable.Rows.Count) - 1; + + if (currentRowIndex != lastLogicalRowIndex) + return; + + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Row, true); + SyncSpreadsheetDocumentFromTable(); + + e.Handled = true; + int insertRowIndex = lastLogicalRowIndex + 1; + ApplySpreadsheetDocumentChange( + document => document.InsertRow(insertRowIndex), + insertRowIndex, + currentColumnIndex); + } + + 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; + } + + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetContextMenu") as ContextMenu; + } + + private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) + { + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void ClearSelectedSpreadsheetCellValues() + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = SpreadsheetDataGrid.SelectedCells + .Select(cell => ( + RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), + ColumnIndex: cell.Column?.DisplayIndex ?? -1)) + .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) + .Distinct() + .ToList(); + + 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; + } + } + + private void SpreadsheetUndoCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = spreadsheetUndoHistory.CanUndo; + 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 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 = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + tableDocument.SetRowHeight(rowIndex, height); + } + + 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 SyncSpreadsheetDocumentFromTable(bool writeText = true) + { + tableDocument ??= EditTextTableDocument.CreateFromText(PassedTextControl.Text); + + tableDocument.ColumnNames = spreadsheetTable.Columns + .Cast() + .Select(column => column.ColumnName) + .ToList(); + + tableDocument.Rows = spreadsheetTable.Rows + .Cast() + .Select(row => spreadsheetTable.Columns + .Cast() + .Select(column => row[column]?.ToString() ?? string.Empty) + .ToList()) + .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 = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + 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 void TrySetClipboardText(string text) + { + try + { + System.Windows.Clipboard.SetDataObject(text, true); + } + catch + { + } + } + + 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 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); + } + + 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); + 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) + { + if (CalcColumn.Width.IsStar) + calcPaneWidth = (int)CalcColumn.ActualWidth; + else + calcPaneWidth = (int)CalcColumn.Width.Value; + } + + HistoryInfo historyInfo = new() + { + ID = historyId, + LanguageTag = LanguageUtilities.GetCurrentInputLanguage().LanguageTag, + LanguageKind = LanguageKind.Global, + CaptureDateTime = DateTimeOffset.Now, + TextContent = PassedTextControl.Text, + SourceMode = TextGrabMode.EditText, + CalcPaneWidth = calcPaneWidth, + HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true, + EditorMode = editorMode, + EditTextTableDocumentJson = tableDocument?.SerializeToJson() + }; + + if (string.IsNullOrWhiteSpace(historyInfo.ID)) + historyInfo.ID = Guid.NewGuid().ToString(); + + 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) { - int calcPaneWidth = 0; - if (ShowCalcPaneMenuItem.IsChecked is true && CalcColumn.Width.Value > 0) - { - if (CalcColumn.Width.IsStar) - calcPaneWidth = (int)CalcColumn.ActualWidth; - else - calcPaneWidth = (int)CalcColumn.Width.Value; - } + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (IoUtilities.IsSpreadsheetFileExtension(existingExtension)) + return 1; - HistoryInfo historyInfo = new() + if (IoUtilities.IsMarkdownFileExtension(existingExtension)) + return 2; + + if (string.Equals(existingExtension, ".txt", StringComparison.OrdinalIgnoreCase)) + return 3; + + if (!string.IsNullOrWhiteSpace(existingExtension)) + return 4; + + return editorMode switch { - ID = historyId, - LanguageTag = LanguageUtilities.GetCurrentInputLanguage().LanguageTag, - LanguageKind = LanguageKind.Global, - CaptureDateTime = DateTimeOffset.Now, - TextContent = PassedTextControl.Text, - SourceMode = TextGrabMode.EditText, - CalcPaneWidth = calcPaneWidth, - HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true + EtwEditorMode.Spreadsheet => 1, + EtwEditorMode.Markdown => 2, + _ => 3 }; + } - if (string.IsNullOrWhiteSpace(historyInfo.ID)) - historyInfo.ID = Guid.NewGuid().ToString(); + private static string GetSpreadsheetSaveExtension(EditTextTableDocument? tableDocument) + { + if (tableDocument is null) + return ".tsv"; - return historyInfo; + 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) @@ -437,16 +1622,40 @@ 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) + { + EtwEditorMode targetMode = isMultipleFiles + ? EtwEditorMode.Text + : IoUtilities.GetEditorModeForPath(pathOfFileToOpen); + + if (IsLoaded) + SetEditorMode(targetMode); + else + editorMode = targetMode; + } + + isLoadingOpenedFile = true; + try + { + PassedTextControl.AppendText(TextContent); - if (KindOpened == OpenContentKind.TextFile - && !isMultipleFiles - && !string.IsNullOrWhiteSpace(TextContent)) - UiTitleBar.Title = $"Edit Text | {Path.GetFileName(OpenedFilePath)}"; + if (!IsLoaded) + return; - PassedTextControl.AppendText(TextContent); + if (editorMode == EtwEditorMode.Spreadsheet) + RefreshSpreadsheetFromText(); + else if (editorMode == EtwEditorMode.Markdown) + RefreshMarkdownFromText(); + } + finally + { + isLoadingOpenedFile = false; + SetOpenedFileState(shouldTrackOpenedFile ? pathOfFileToOpen : null); + } } private void AboutMenuItem_Click(object sender, RoutedEventArgs e) @@ -949,6 +2158,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) { @@ -1364,7 +2607,11 @@ private void LoadRecentTextHistory() if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) { + ResetSpreadsheetUndoHistory(); PassedTextControl.Text = selectedHistory.TextContent; + tableDocument = EditTextTableDocument.TryDeserialize(selectedHistory.EditTextTableDocumentJson); + editorMode = selectedHistory.EditorMode; + SetEditorMode(editorMode); return; } @@ -1565,7 +2812,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 = OpenDocumentFilter, DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; @@ -1609,13 +2856,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 +2885,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; @@ -1961,46 +3356,162 @@ 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) + { + SyncTextFromActiveEditor(); + _ = 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 +3525,13 @@ private void SelectAllMenuItem_Click(Object? sender = null, RoutedEventArgs? e = if (!IsLoaded) return; + if (editorMode == EtwEditorMode.Spreadsheet) + { + SpreadsheetDataGrid.SelectAllCells(); + SpreadsheetDataGrid.Focus(); + return; + } + PassedTextControl.SelectAll(); } @@ -2063,6 +3581,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 +3594,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 +3633,9 @@ 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)); + RoutedCommand newFullscreenGrab = new(); _ = newFullscreenGrab.InputGestures.Add(new KeyGesture(Key.F, ModifierKeys.Control)); _ = CommandBindings.Add(new CommandBinding(newFullscreenGrab, KeyedCtrlF)); @@ -2378,6 +3915,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 +4023,12 @@ private void UpdateLineAndColumnText() private void UpdateSelectionSpecificUI() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + return; + } + string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) @@ -2733,11 +4332,25 @@ 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); + + if (windowSource is not null) + windowSource.RemoveHook(EditTextWindowMessageHook); + string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -2774,23 +4387,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 +4490,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) @@ -3301,6 +4956,8 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) else PassedTextControl.TextWrapping = TextWrapping.NoWrap; + ApplyMarkdownWrapSetting(); + DefaultSettings.EditWindowIsWordWrapOn = WrapTextMenuItem.IsChecked; } From 77ba7f2b1e2c336dd5e23a44a52993faf96842c2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 24 Apr 2026 20:24:31 -0500 Subject: [PATCH 12/81] Update dependencies and add document file associations - Upgrade NuGet packages in Tests.csproj and Text-Grab.csproj, including Magick.NET, WindowsAppSDK, WPF-UI, and coverlet.collector - Add Markdig for Markdown support - Enhance app manifest to associate .csv, .tsv, .tab, .md, .markdown, and .txt files with "Open with Text Grab" verb - No functional code changes; updates focus on dependencies and file type integration --- Tests/Tests.csproj | 4 ++-- Text-Grab-Package/Package.appxmanifest | 16 ++++++++++++++++ Text-Grab/Text-Grab.csproj | 21 +++++++++++---------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 97ada3c4..a6f350f3 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index bccb2398..b3fe9a6f 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -80,6 +80,22 @@ + + + + .csv + .tsv + .tab + .md + .markdown + .txt + + + Open with Text Grab + + + + diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 8ebd0208..16f1fad4 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -54,18 +54,19 @@ - - - + + + + - - - - + + + + - - + + @@ -120,4 +121,4 @@ Settings.Designer.cs - \ No newline at end of file + From 46ab38fa4e3590dfec58c3c02317cc3d6cefbd3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:18:16 +0000 Subject: [PATCH 13/81] fix: apply PR review feedback - CodeSpan, table cell newlines, URI safety, indentation, file open, save sync Agent-Logs-Url: https://github.com/TheJoeFin/Text-Grab/sessions/63f3f784-4485-4547-a38c-4d37de922366 Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- .claude/settings.local.json | 5 +---- Text-Grab/Models/HistoryInfo.cs | 4 ++-- Text-Grab/Utilities/MarkdownDocumentUtilities.cs | 11 ++++++++--- Text-Grab/Views/EditTextWindow.xaml.cs | 3 +-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 80c27172..6aff92af 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,10 +18,7 @@ "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 *)", - "Bash(pdm api *)", - "Read(//c/Users/josep/.claude/skills/pdm/**)" + "Bash(pdm api *)" ], "deny": [] } diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 4f6acd26..e70120fb 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -44,9 +44,9 @@ public HistoryInfo() [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool UsedUiAutomation { get; set; } - public bool HasCalcPaneOpen { get; set; } = false; + public bool HasCalcPaneOpen { get; set; } = false; - public int CalcPaneWidth { get; set; } = 0; + public int CalcPaneWidth { get; set; } = 0; public EtwEditorMode EditorMode { get; set; } = EtwEditorMode.Text; diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs index d04152ed..b1d9405a 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -356,8 +356,12 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, case LinkInline linkInline when !linkInline.IsImage: Hyperlink hyperlink = new(); - if (!string.IsNullOrWhiteSpace(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url)) - hyperlink.NavigateUri = new Uri(linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl()! : linkInline.Url!, UriKind.RelativeOrAbsolute); + 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) @@ -512,7 +516,7 @@ private static string SerializeTableCell(WpfTableCell cell) string rawText = NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); return rawText .Replace("|", "\\|", StringComparison.Ordinal) - .Replace(Environment.NewLine, "
", StringComparison.Ordinal); + .Replace("\n", "
", StringComparison.Ordinal); } private static string SerializeInlines(InlineCollection inlines, bool preserveLiteralMarkdown) @@ -536,6 +540,7 @@ private static void WriteInline(StringBuilder builder, WpfInline inline, bool pr builder.Append(GetInlineRole(run) switch { MarkdownInlineRole.TaskListMarker => GetTaskListMarkerChecked(run) ? "[x]" : "[ ]", + MarkdownInlineRole.CodeSpan => $"`{NormalizeDocumentText(run.Text)}`", MarkdownInlineRole.LiteralMarkdown => run.Text, _ when preserveLiteralMarkdown => run.Text, _ => EscapeMarkdownText(run.Text) diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 4a439cd1..fae39f00 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1641,7 +1641,7 @@ internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = fal isLoadingOpenedFile = true; try { - PassedTextControl.AppendText(TextContent); + PassedTextControl.Text = TextContent; if (!IsLoaded) return; @@ -3361,7 +3361,6 @@ private void SaveAsBTN_Click(object sender, RoutedEventArgs e) private void SaveBTN_Click(object sender, RoutedEventArgs e) { - SyncTextFromActiveEditor(); _ = SaveCurrentDocument(); } From 216b1045ec0f52b31197334a10baac26c2b11f41 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 09:38:45 -0500 Subject: [PATCH 14/81] Expand EditTextWindow actions and add action catalog tests - Add new spreadsheet, translation, AI, and utility buttons to ButtonInfo.cs with appropriate handlers and icons - Update translation prompt to use local alphabet/characters - Add event handlers for new actions and editor mode toggles in EditTextWindow.xaml.cs - Refactor list initializations with C# collection expressions; add ToggleMenuItem helper - Add EditTextWindowActionCatalogTests to verify all ButtonInfo actions are valid and expected - Minor code cleanups and naming consistency improvements --- Tests/EditTextWindowActionCatalogTests.cs | 110 +++++++ Text-Grab/Models/ButtonInfo.cs | 382 +++++++++++++++++++++- Text-Grab/Utilities/WindowsAiUtilities.cs | 2 +- Text-Grab/Views/EditTextWindow.xaml.cs | 82 ++++- 4 files changed, 549 insertions(+), 27 deletions(-) create mode 100644 Tests/EditTextWindowActionCatalogTests.cs diff --git a/Tests/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs new file mode 100644 index 00000000..fface42f --- /dev/null +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -0,0 +1,110 @@ +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); + } + } + + [Fact] + public void AllButtons_ContainsExpectedEditTextActions() + { + ExpectedButtonAction[] expectedButtons = + [ + new("OCR Paste", Command: "OcrPasteCommand"), + new("Write .txt File For Each Image", ClickEvent: "ToggleWriteTxtFileForEachImage_Click"), + new("Close", ClickEvent: "CloseMenuItem_Click"), + new("Correct Common GUID/UUID Errors", ClickEvent: "CorrectGuid_Click"), + new("Transpose Table", Command: "TransposeTableCmd"), + new("Add Spreadsheet Row", ClickEvent: "AddSpreadsheetRowMenuItem_Click"), + new("Add Spreadsheet Column", ClickEvent: "AddSpreadsheetColumnMenuItem_Click"), + new("Copy Selected Spreadsheet Cells", ClickEvent: "CopySpreadsheetSelectionMenuItem_Click"), + new("Copy Selected Spreadsheet Rows", ClickEvent: "CopySpreadsheetRowsMenuItem_Click"), + new("Copy Current Spreadsheet Column", ClickEvent: "CopySpreadsheetColumnMenuItem_Click"), + new("Move Spreadsheet Row Up", ClickEvent: "MoveSpreadsheetRowUpMenuItem_Click"), + new("Move Spreadsheet Row Down", ClickEvent: "MoveSpreadsheetRowDownMenuItem_Click"), + new("Delete Spreadsheet Row", ClickEvent: "DeleteSpreadsheetRowMenuItem_Click"), + new("Move Spreadsheet Column Left", ClickEvent: "MoveSpreadsheetColumnLeftMenuItem_Click"), + new("Move Spreadsheet Column Right", ClickEvent: "MoveSpreadsheetColumnRightMenuItem_Click"), + new("Delete Spreadsheet Column", ClickEvent: "DeleteSpreadsheetColumnMenuItem_Click"), + new("Enter Raw Text Mode", ClickEvent: "EnterRawTextMode_Click"), + new("Enter Spreadsheet Mode", ClickEvent: "EnterSpreadsheetMode_Click"), + new("Enter Markdown Mode", ClickEvent: "EnterMarkdownMode_Click"), + new("Toggle Show Math Errors", ClickEvent: "ToggleShowMathErrors_Click"), + new("Toggle Calculation Pane", ClickEvent: "CalcToggleButton_Click"), + new("Copy All Calculation Results", ClickEvent: "CalcCopyAllButton_Click"), + new("Calculation Pane Help", ClickEvent: "CalcInfoButton_Click"), + new("Toggle Always On Top", ClickEvent: "ToggleAlwaysOnTop_Click"), + new("Toggle Hide Bottom Bar", ClickEvent: "ToggleHideBottomBar_Click"), + new("Toggle Fullscreen on Launch", ClickEvent: "ToggleLaunchFullscreenOnLoad_Click"), + new("Toggle Restore Window Position", ClickEvent: "ToggleRestorePosition_Click"), + new("Restore This Window Position", ClickEvent: "RestoreThisPosition_Click"), + new("Toggle Margins", ClickEvent: "ToggleMargins_Click"), + new("Toggle Wrap Text", ClickEvent: "ToggleWrapText_Click"), + new("Font...", ClickEvent: "FontMenuItem_Click"), + new("Grab Previous Region", ClickEvent: "PreviousRegion_Click"), + new("Edit Last Grab", ClickEvent: "OpenLastAsGrabFrameMenuItem_Click"), + new("Contact Developer", ClickEvent: "ContactMenuItem_Click"), + new("Rate and Review", ClickEvent: "RateAndReview_Click"), + new("Feedback...", ClickEvent: "FeedbackMenuItem_Click"), + new("About", ClickEvent: "AboutMenuItem_Click"), + new("Select All", ClickEvent: "SelectAllMenuItem_Click"), + new("Select None", ClickEvent: "SelectNoneMenuItem_Click"), + new("Delete Selected Text", ClickEvent: "DeleteSelectedTextMenuItem_Click"), + new("Show Character Details", ClickEvent: "CharDetailsButton_Click"), + new("Find Similar Matches", ClickEvent: "SimilarMatchesButton_Click"), + new("Open Regex Pattern Search", ClickEvent: "RegexPatternButton_Click"), + new("Save Regex Pattern", ClickEvent: "SavePatternMenuItem_Click"), + new("Explain Regex Pattern", ClickEvent: "ExplainPatternMenuItem_Click"), + new("Summarize Paragraph", ClickEvent: "SummarizeMenuItem_Click"), + new("Rewrite with Local AI", ClickEvent: "RewriteMenuItem_Click"), + new("Convert to Table", ClickEvent: "ConvertTableMenuItem_Click"), + new("Translate to System Language", ClickEvent: "TranslateToSystemLanguageMenuItem_Click"), + new("Translate to English", ClickEvent: "TranslateToEnglish_Click"), + new("Translate to Spanish", ClickEvent: "TranslateToSpanish_Click"), + new("Translate to French", ClickEvent: "TranslateToFrench_Click"), + new("Translate to German", ClickEvent: "TranslateToGerman_Click"), + new("Translate to Italian", ClickEvent: "TranslateToItalian_Click"), + new("Translate to Portuguese", ClickEvent: "TranslateToPortuguese_Click"), + new("Translate to Russian", ClickEvent: "TranslateToRussian_Click"), + new("Translate to Japanese", ClickEvent: "TranslateToJapanese_Click"), + new("Translate to Chinese (Simplified)", ClickEvent: "TranslateToChineseSimplified_Click"), + new("Translate to Korean", ClickEvent: "TranslateToKorean_Click"), + new("Translate to Arabic", ClickEvent: "TranslateToArabic_Click"), + new("Translate to Hindi", ClickEvent: "TranslateToHindi_Click"), + new("Extract RegEx", ClickEvent: "ExtractRegexMenuItem_Click"), + new("Learn About Local AI Features...", ClickEvent: "LearnAiMenuItem_Click"), + ]; + + foreach (ExpectedButtonAction expected in expectedButtons) + { + ButtonInfo button = Assert.Single(ButtonInfo.AllButtons, button => button.ButtonText == expected.ButtonText); + + if (expected.Command is not null) + Assert.Equal(expected.Command, button.Command); + + if (expected.ClickEvent is not null) + Assert.Equal(expected.ClickEvent, button.ClickEvent); + } + } +} diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 442af591..dd32483b 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -300,7 +300,7 @@ public static List AllButtons OrderNumber = 2.3, ButtonText = "OCR Paste", SymbolText = "", - Command = "PasteCommand", + Command = "OcrPasteCommand", SymbolIcon = SymbolRegular.ClipboardImage24 }, new() @@ -490,9 +490,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 +520,382 @@ 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.DocumentTextToolbox24 + }, + new() + { + OrderNumber = 7.1, + ButtonText = "Toggle Show Math 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 + }, + new() + { + OrderNumber = 8.2, + ButtonText = "Rewrite with Local AI", + ClickEvent = "RewriteMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24 + }, + new() + { + OrderNumber = 8.3, + ButtonText = "Convert to Table", + ClickEvent = "ConvertTableMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24 + }, + new() + { + OrderNumber = 8.4, + ButtonText = "Translate to System Language", + ClickEvent = "TranslateToSystemLanguageMenuItem_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.41, + ButtonText = "Translate to English", + ClickEvent = "TranslateToEnglish_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.42, + ButtonText = "Translate to Spanish", + ClickEvent = "TranslateToSpanish_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.43, + ButtonText = "Translate to French", + ClickEvent = "TranslateToFrench_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.44, + ButtonText = "Translate to German", + ClickEvent = "TranslateToGerman_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.45, + ButtonText = "Translate to Italian", + ClickEvent = "TranslateToItalian_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.46, + ButtonText = "Translate to Portuguese", + ClickEvent = "TranslateToPortuguese_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.47, + ButtonText = "Translate to Russian", + ClickEvent = "TranslateToRussian_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.48, + ButtonText = "Translate to Japanese", + ClickEvent = "TranslateToJapanese_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.49, + ButtonText = "Translate to Chinese (Simplified)", + ClickEvent = "TranslateToChineseSimplified_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.5, + ButtonText = "Translate to Korean", + ClickEvent = "TranslateToKorean_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.51, + ButtonText = "Translate to Arabic", + ClickEvent = "TranslateToArabic_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.52, + ButtonText = "Translate to Hindi", + ClickEvent = "TranslateToHindi_Click", + SymbolIcon = SymbolRegular.Translate24 + }, + new() + { + OrderNumber = 8.6, + ButtonText = "Extract RegEx", + ClickEvent = "ExtractRegexMenuItem_Click", + SymbolIcon = SymbolRegular.TextWholeWord20 + }, + 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/Utilities/WindowsAiUtilities.cs b/Text-Grab/Utilities/WindowsAiUtilities.cs index 4f386490..c8b2d79f 100644 --- a/Text-Grab/Utilities/WindowsAiUtilities.cs +++ b/Text-Grab/Utilities/WindowsAiUtilities.cs @@ -20,7 +20,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; diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index fae39f00..a0f6dc6d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,7 +1,7 @@ using Humanizer; using System; -using System.ComponentModel; using System.Collections.Generic; +using System.ComponentModel; using System.Data; using System.Diagnostics; using System.Globalization; @@ -9,7 +9,6 @@ using System.Linq; using System.Net; using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -34,8 +33,6 @@ 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; @@ -584,7 +581,7 @@ private void CopySpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) { - List selectedRows = SpreadsheetDataGrid.SelectedItems.OfType().ToList(); + List selectedRows = [.. SpreadsheetDataGrid.SelectedItems.OfType()]; if (selectedRows.Count == 0 && SpreadsheetDataGrid.CurrentItem is DataRowView currentRow) selectedRows.Add(currentRow); @@ -1195,13 +1192,12 @@ private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCel private void ClearSelectedSpreadsheetCellValues() { - List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = SpreadsheetDataGrid.SelectedCells + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells .Select(cell => ( RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), ColumnIndex: cell.Column?.DisplayIndex ?? -1)) .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) - .Distinct() - .ToList(); + .Distinct()]; if (selectedCellCoordinates.Count == 0) return; @@ -1327,22 +1323,51 @@ private void EditorModeMenuItem_Click(object sender, RoutedEventArgs e) 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 + tableDocument.ColumnNames = [.. spreadsheetTable.Columns .Cast() - .Select(column => column.ColumnName) - .ToList(); + .Select(column => column.ColumnName)]; - tableDocument.Rows = spreadsheetTable.Rows + tableDocument.Rows = [.. spreadsheetTable.Rows .Cast() .Select(row => spreadsheetTable.Columns .Cast() .Select(column => row[column]?.ToString() ?? string.Empty) - .ToList()) - .ToList(); + .ToList())]; int furthestNonEmptyRowIndex = -1; int furthestNonEmptyColumnIndex = -1; @@ -2557,7 +2582,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, @@ -4347,8 +4372,7 @@ private void Window_Closed(object sender, EventArgs e) DetachSpreadsheetColumnWidthTracking(); System.Windows.DataObject.RemovePastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); - if (windowSource is not null) - windowSource.RemoveHook(EditTextWindowMessageHook); + windowSource?.RemoveHook(EditTextWindowMessageHook); string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -5058,6 +5082,30 @@ 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(); From d65e500b14e5bec8768d8a07fd3cf2acd4e3fa1b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 16:11:01 -0500 Subject: [PATCH 15/81] Enhance spreadsheet editing and text transform support Refactored EditTextWindow to unify text and spreadsheet cell transformation logic, enabling all text operations (replace, case toggle, trim, AI transforms, etc.) to work on selected spreadsheet cells. Added helper methods for cell selection and value updates. Improved word selection for spreadsheet cells. Updated command handlers and context menus for spreadsheet awareness. Enhanced CursorWordBoundaries for edge cases and added related unit tests. Minor UI tweaks to spreadsheet context menu. --- Tests/EditTextWindowSpreadsheetTests.cs | 104 +++++ Tests/StringMethodTests.cs | 22 + Text-Grab/Utilities/StringMethods.cs | 20 +- Text-Grab/Views/EditTextWindow.xaml | 2 +- Text-Grab/Views/EditTextWindow.xaml.cs | 510 +++++++++++++++++------- 5 files changed, 509 insertions(+), 149 deletions(-) diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index ce9d1971..89eadb6b 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -1,5 +1,6 @@ using System.Data; using Text_Grab; +using Text_Grab.Models; namespace Tests; @@ -33,4 +34,107 @@ public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells() Assert.Equal("b2", dataTable.Rows[1][1]); Assert.Equal(string.Empty, dataTable.Rows[1][2]); } + + [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()); + } } diff --git a/Tests/StringMethodTests.cs b/Tests/StringMethodTests.cs index a0412b95..6fb121d1 100644 --- a/Tests/StringMethodTests.cs +++ b/Tests/StringMethodTests.cs @@ -35,6 +35,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 diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab/Utilities/StringMethods.cs index a026c8eb..f349808a 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; diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 4027e9f4..73352783 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -83,7 +83,7 @@ - + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index a0f6dc6d..f078286d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -99,6 +99,7 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow 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; @@ -598,18 +599,13 @@ private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) private void CopySpreadsheetSelectionMenuItem_Click(object sender, RoutedEventArgs e) { - if (SpreadsheetDataGrid.SelectedCells.Count == 0) + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + if (selectedCellCoordinates.Count == 0) return; - string selectionText = string.Join( - Environment.NewLine, - SpreadsheetDataGrid.SelectedCells - .GroupBy(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) - .OrderBy(group => group.Key) - .Select(group => string.Join( - "\t", - group.OrderBy(cell => cell.Column.DisplayIndex) - .Select(cell => ((cell.Item as DataRowView)?.Row[cell.Column.DisplayIndex] ?? string.Empty).ToString())))); + string selectionText = BuildSpreadsheetSelectionText(spreadsheetTable, selectedCellCoordinates); + if (string.IsNullOrEmpty(selectionText)) + return; TrySetClipboardText(selectionText); } @@ -724,6 +720,7 @@ private void FocusSpreadsheetCell(int rowIndex, int columnIndex, bool beginEdit SpreadsheetDataGrid.SelectedCells.Clear(); SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); if (beginEdit) @@ -818,6 +815,7 @@ private void RebuildSpreadsheetTable() spreadsheetTable.EndInit(); SpreadsheetDataGrid.ItemsSource = spreadsheetTable.DefaultView; + selectedSpreadsheetCellCoordinates = []; SpreadsheetDataGrid.Columns.Clear(); for (int columnIndex = 0; columnIndex < spreadsheetTable.Columns.Count; columnIndex++) @@ -1186,18 +1184,15 @@ private void SpreadsheetDataGrid_PreviewMouseRightButtonDown(object sender, Mous private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) { + UpdateSelectedSpreadsheetCellCoordinates(); + if (editorMode == EtwEditorMode.Spreadsheet) UpdateLineAndColumnText(); } private void ClearSelectedSpreadsheetCellValues() { - List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells - .Select(cell => ( - RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), - ColumnIndex: cell.Column?.DisplayIndex ?? -1)) - .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) - .Distinct()]; + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); if (selectedCellCoordinates.Count == 0) return; @@ -1230,6 +1225,216 @@ internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable } } + 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; + } + } + + 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)> 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()) @@ -1503,6 +1708,7 @@ private void SelectSpreadsheetColumn(int columnIndex) SpreadsheetDataGrid.ScrollIntoView(firstRowItem, column); } + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); UpdateLineAndColumnText(); } @@ -1521,6 +1727,7 @@ private void SelectSpreadsheetRow(object rowItem) SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, firstColumn); SpreadsheetDataGrid.ScrollIntoView(rowItem, firstColumn); + UpdateSelectedSpreadsheetCellCoordinates(); SpreadsheetDataGrid.Focus(); UpdateLineAndColumnText(); } @@ -2153,6 +2360,46 @@ public string GetSelectedTextOrAllText() return textToModify; } + 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(); @@ -3289,37 +3536,13 @@ private void RemoveDuplicateLines_Click(object sender, RoutedEventArgs e) 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; + 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) @@ -3591,11 +3814,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(); @@ -3761,25 +4042,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) @@ -3836,6 +4107,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) @@ -3869,54 +4157,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) @@ -4986,30 +5263,16 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) 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 { @@ -5028,17 +5291,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 { @@ -5048,18 +5304,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 { @@ -5108,22 +5357,11 @@ private async void TranslateToSystemLanguageMenuItem_Click(object sender, Routed 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) { From 42ecbd5cfb07314c767248e1c379c8365f810332 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 16:22:19 -0500 Subject: [PATCH 16/81] Remove pointless test checking values in AllButtons collection --- Tests/EditTextWindowActionCatalogTests.cs | 82 ----------------------- 1 file changed, 82 deletions(-) diff --git a/Tests/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs index fface42f..b94fb194 100644 --- a/Tests/EditTextWindowActionCatalogTests.cs +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -25,86 +25,4 @@ public void AllButtons_UsesResolvableEditTextCommandsAndClickEvents() Assert.Contains(button.ClickEvent, methodNames); } } - - [Fact] - public void AllButtons_ContainsExpectedEditTextActions() - { - ExpectedButtonAction[] expectedButtons = - [ - new("OCR Paste", Command: "OcrPasteCommand"), - new("Write .txt File For Each Image", ClickEvent: "ToggleWriteTxtFileForEachImage_Click"), - new("Close", ClickEvent: "CloseMenuItem_Click"), - new("Correct Common GUID/UUID Errors", ClickEvent: "CorrectGuid_Click"), - new("Transpose Table", Command: "TransposeTableCmd"), - new("Add Spreadsheet Row", ClickEvent: "AddSpreadsheetRowMenuItem_Click"), - new("Add Spreadsheet Column", ClickEvent: "AddSpreadsheetColumnMenuItem_Click"), - new("Copy Selected Spreadsheet Cells", ClickEvent: "CopySpreadsheetSelectionMenuItem_Click"), - new("Copy Selected Spreadsheet Rows", ClickEvent: "CopySpreadsheetRowsMenuItem_Click"), - new("Copy Current Spreadsheet Column", ClickEvent: "CopySpreadsheetColumnMenuItem_Click"), - new("Move Spreadsheet Row Up", ClickEvent: "MoveSpreadsheetRowUpMenuItem_Click"), - new("Move Spreadsheet Row Down", ClickEvent: "MoveSpreadsheetRowDownMenuItem_Click"), - new("Delete Spreadsheet Row", ClickEvent: "DeleteSpreadsheetRowMenuItem_Click"), - new("Move Spreadsheet Column Left", ClickEvent: "MoveSpreadsheetColumnLeftMenuItem_Click"), - new("Move Spreadsheet Column Right", ClickEvent: "MoveSpreadsheetColumnRightMenuItem_Click"), - new("Delete Spreadsheet Column", ClickEvent: "DeleteSpreadsheetColumnMenuItem_Click"), - new("Enter Raw Text Mode", ClickEvent: "EnterRawTextMode_Click"), - new("Enter Spreadsheet Mode", ClickEvent: "EnterSpreadsheetMode_Click"), - new("Enter Markdown Mode", ClickEvent: "EnterMarkdownMode_Click"), - new("Toggle Show Math Errors", ClickEvent: "ToggleShowMathErrors_Click"), - new("Toggle Calculation Pane", ClickEvent: "CalcToggleButton_Click"), - new("Copy All Calculation Results", ClickEvent: "CalcCopyAllButton_Click"), - new("Calculation Pane Help", ClickEvent: "CalcInfoButton_Click"), - new("Toggle Always On Top", ClickEvent: "ToggleAlwaysOnTop_Click"), - new("Toggle Hide Bottom Bar", ClickEvent: "ToggleHideBottomBar_Click"), - new("Toggle Fullscreen on Launch", ClickEvent: "ToggleLaunchFullscreenOnLoad_Click"), - new("Toggle Restore Window Position", ClickEvent: "ToggleRestorePosition_Click"), - new("Restore This Window Position", ClickEvent: "RestoreThisPosition_Click"), - new("Toggle Margins", ClickEvent: "ToggleMargins_Click"), - new("Toggle Wrap Text", ClickEvent: "ToggleWrapText_Click"), - new("Font...", ClickEvent: "FontMenuItem_Click"), - new("Grab Previous Region", ClickEvent: "PreviousRegion_Click"), - new("Edit Last Grab", ClickEvent: "OpenLastAsGrabFrameMenuItem_Click"), - new("Contact Developer", ClickEvent: "ContactMenuItem_Click"), - new("Rate and Review", ClickEvent: "RateAndReview_Click"), - new("Feedback...", ClickEvent: "FeedbackMenuItem_Click"), - new("About", ClickEvent: "AboutMenuItem_Click"), - new("Select All", ClickEvent: "SelectAllMenuItem_Click"), - new("Select None", ClickEvent: "SelectNoneMenuItem_Click"), - new("Delete Selected Text", ClickEvent: "DeleteSelectedTextMenuItem_Click"), - new("Show Character Details", ClickEvent: "CharDetailsButton_Click"), - new("Find Similar Matches", ClickEvent: "SimilarMatchesButton_Click"), - new("Open Regex Pattern Search", ClickEvent: "RegexPatternButton_Click"), - new("Save Regex Pattern", ClickEvent: "SavePatternMenuItem_Click"), - new("Explain Regex Pattern", ClickEvent: "ExplainPatternMenuItem_Click"), - new("Summarize Paragraph", ClickEvent: "SummarizeMenuItem_Click"), - new("Rewrite with Local AI", ClickEvent: "RewriteMenuItem_Click"), - new("Convert to Table", ClickEvent: "ConvertTableMenuItem_Click"), - new("Translate to System Language", ClickEvent: "TranslateToSystemLanguageMenuItem_Click"), - new("Translate to English", ClickEvent: "TranslateToEnglish_Click"), - new("Translate to Spanish", ClickEvent: "TranslateToSpanish_Click"), - new("Translate to French", ClickEvent: "TranslateToFrench_Click"), - new("Translate to German", ClickEvent: "TranslateToGerman_Click"), - new("Translate to Italian", ClickEvent: "TranslateToItalian_Click"), - new("Translate to Portuguese", ClickEvent: "TranslateToPortuguese_Click"), - new("Translate to Russian", ClickEvent: "TranslateToRussian_Click"), - new("Translate to Japanese", ClickEvent: "TranslateToJapanese_Click"), - new("Translate to Chinese (Simplified)", ClickEvent: "TranslateToChineseSimplified_Click"), - new("Translate to Korean", ClickEvent: "TranslateToKorean_Click"), - new("Translate to Arabic", ClickEvent: "TranslateToArabic_Click"), - new("Translate to Hindi", ClickEvent: "TranslateToHindi_Click"), - new("Extract RegEx", ClickEvent: "ExtractRegexMenuItem_Click"), - new("Learn About Local AI Features...", ClickEvent: "LearnAiMenuItem_Click"), - ]; - - foreach (ExpectedButtonAction expected in expectedButtons) - { - ButtonInfo button = Assert.Single(ButtonInfo.AllButtons, button => button.ButtonText == expected.ButtonText); - - if (expected.Command is not null) - Assert.Equal(expected.Command, button.Command); - - if (expected.ClickEvent is not null) - Assert.Equal(expected.ClickEvent, button.ClickEvent); - } - } } From 6f93d78dcbdeab8ee7b3a58fe0ac91bd0f0095a8 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 18:38:31 -0500 Subject: [PATCH 17/81] Add PDF OCR support and context menu integration - Introduce OpenContentKind.PdfDocument and PDF extension helpers - Support PDFs in context menu and "Open With" registration - Add FileUtilities.GetVisualDocumentFilter for images/PDFs - Route PDF files to new PdfDocumentRenderer for native text and OCR - Refactor to treat images and PDFs as visual documents - No breaking changes to existing image workflows --- Text-Grab/Enums.cs | 1 + Text-Grab/Utilities/ContextMenuUtilities.cs | 29 +- Text-Grab/Utilities/FileUtilities.cs | 53 ++- Text-Grab/Utilities/ImplementAppOptions.cs | 14 +- Text-Grab/Utilities/IoUtilities.cs | 47 ++- Text-Grab/Utilities/OcrUtilities.cs | 21 +- Text-Grab/Utilities/PdfDocumentRenderer.cs | 435 ++++++++++++++++++++ 7 files changed, 555 insertions(+), 45 deletions(-) create mode 100644 Text-Grab/Utilities/PdfDocumentRenderer.cs diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index 52824fa6..4ed5a1f5 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 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/FileUtilities.cs b/Text-Grab/Utilities/FileUtilities.cs index 73bae77d..cfe0edc7 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,23 @@ 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) - { - if (codec.FilenameExtension is not string extension) - continue; + string imageExtensions = GetImageExtensionsFilterPattern(); + return string.IsNullOrEmpty(imageExtensions) ? string.Empty : $"Image files|{imageExtensions}"; + } - imageExtensions = $"{imageExtensions}{separator}{extension.ToLower()}"; - separator = ";"; - imageFilters.Add($"{codec.FormatDescription} files ({extension.ToLower()})", extension.ToLower()); - } - string result = string.Empty; - separator = ""; + public static string GetVisualDocumentFilter() + { + string imageExtensions = GetImageExtensionsFilterPattern(); + string pdfExtensions = string.Join(";", IoUtilities.PdfExtensions.Select(extension => $"*{extension}")); + string combinedExtensions = string.Join(";", new[] { imageExtensions, pdfExtensions }.Where(pattern => !string.IsNullOrWhiteSpace(pattern))); + string imageFilter = GetImageFilter(); - if (!string.IsNullOrEmpty(imageExtensions)) + return string.Join("|", new[] { - result += $"{separator}Image files|{imageExtensions}"; - } - return result; + $"Image and PDF files|{combinedExtensions}", + $"PDF files|{pdfExtensions}", + imageFilter + }); } public static string GetPathToLocalFile(string imageRelativePath) @@ -99,6 +96,26 @@ 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 async Task GetImageFilePackaged(string fileName, FileStorageKind storageKind) { StorageFolder folder = await GetStorageFolderPackaged(fileName, storageKind); 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 b05e90cc..698bf16b 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -11,6 +11,7 @@ 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"]; @@ -30,6 +31,35 @@ 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)) @@ -59,15 +89,28 @@ public static EtwEditorMode GetEditorModeForPath(string? path) 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/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index eed11f63..aa14648d 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -540,8 +540,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))) + { + PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(absolutePath); + return await pdfDocument.ExtractTextAsync(language); + } + + using Bitmap bmp = LoadBitmapFromFile(absolutePath); return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); } @@ -657,8 +664,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))) + { + 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..c5bbb47a --- /dev/null +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -0,0 +1,435 @@ +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 readonly WinPdfDocument renderDocument; + private readonly PigPdfDocument textDocument; + private readonly Dictionary pageCache = []; + + 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)) + 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); + pageCache[pageIndex] = pageContent; + 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 imageOcrLines = await GetOcrLinesAsync( + pageContent.RenderedPage, + resolvedLanguage, + sourceRect => ShouldIncludeOcrLine(sourceRect, pageContent.ImageRegions)); + + 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."); + } +} From be3b874c040d1f71f81cc8c56c00249bd0f78571 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 18:39:37 -0500 Subject: [PATCH 18/81] Add PDF text selection and navigation to GrabFrame - Introduce PdfTextLineOverlay and PdfTextCanvas for PDF text selection - Add PDF page navigation controls to GrabFrame UI - Support loading, rendering, and extracting text from PDFs - Enable selection, search, and copy of PDF text lines - Update pan/zoom logic for PDF overlays and spacebar-panning - Refactor event handlers and utilities for PDF support - Update dialogs, menus, and help text to include PDFs - Improve XAML formatting and documentation consistency --- Text-Grab/Controls/PdfTextLineOverlay.cs | 90 +++++ Text-Grab/Controls/ZoomBorder.cs | 38 +- Text-Grab/Pages/GeneralSettings.xaml | 8 +- Text-Grab/Styles/ButtonStyles.xaml | 24 +- Text-Grab/Styles/DataGridStyles.xaml | 10 +- Text-Grab/Styles/ListViewScrollFix.xaml | 26 +- Text-Grab/Styles/TextBoxStyles.xaml | 3 +- Text-Grab/Views/EditTextWindow.xaml | 24 +- Text-Grab/Views/EditTextWindow.xaml.cs | 2 +- Text-Grab/Views/FirstRunWindow.xaml | 2 +- Text-Grab/Views/GrabFrame.xaml | 71 +++- Text-Grab/Views/GrabFrame.xaml.cs | 442 +++++++++++++++++++++-- 12 files changed, 662 insertions(+), 78 deletions(-) create mode 100644 Text-Grab/Controls/PdfTextLineOverlay.cs 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/ZoomBorder.cs b/Text-Grab/Controls/ZoomBorder.cs index dc29ee82..198dcb97 100644 --- a/Text-Grab/Controls/ZoomBorder.cs +++ b/Text-Grab/Controls/ZoomBorder.cs @@ -3,6 +3,7 @@ using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; +using System.Windows.Media.Media3D; // From StackOverFlow: // https://stackoverflow.com/questions/741956/pan-zoom-image @@ -41,6 +42,8 @@ public override UIElement Child public bool CanZoom { get; set; } = true; + public bool RequireSpaceToPan { get; set; } = false; + public void Initialize(UIElement element) { child = element; @@ -87,6 +90,36 @@ public void Reset() CanPan = false; } + private bool IsPanGestureActive() => !RequireSpaceToPan || 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) @@ -117,7 +150,7 @@ private void Child_MouseWheel(object sender, MouseWheelEventArgs e) private void Child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { - if (child is null) + if (child is null || !IsPanGestureActive() || BlocksPanFromSource(e.OriginalSource)) return; TranslateTransform tt = GetTranslateTransform(child); @@ -142,7 +175,7 @@ private void Child_PreviewMouseRightButtonDown(object sender, MouseButtonEventAr private void Child_MouseMove(object sender, MouseEventArgs e) { - if (e.OriginalSource is TextBox) + if (BlocksPanFromSource(e.OriginalSource)) return; if (child is null @@ -150,6 +183,7 @@ private void Child_MouseMove(object sender, MouseEventArgs e) || st.ScaleX == 1.0 || Mouse.LeftButton == MouseButtonState.Released || !CanPan + || !IsPanGestureActive() || KeyboardExtensions.IsShiftDown() || KeyboardExtensions.IsCtrlDown()) { diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 80794c77..2e326eb6 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -236,11 +236,11 @@ Checked="AddToContextMenuCheckBox_Checked" Unchecked="AddToContextMenuCheckBox_Unchecked"> - 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. @@ -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}}}" /> 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}" /> + Data="{Binding Content, + RelativeSource={RelativeSource TemplatedParent}}"> diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 73352783..69d60201 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -293,9 +293,7 @@ x:Name="AddRemoveAtMenuItem" Click="AddRemoveAtMenuItem_Click" Header="_Add, Remove, Limit..." /> - + - + • 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. + • Drop an image or PDF onto the Grab Frame to view it 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. diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index e6275d84..4bdd3675 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,41 @@ 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}" /> + + Visibility="{Binding Visibility, + ElementName=SearchBox, + Mode=OneWay}" /> + + + + + pdfTextLineOverlays = []; private const string TargetLanguageMenuHeader = "Target Language"; #endregion Fields @@ -114,9 +118,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 +130,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 +145,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 +206,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 @@ -544,6 +548,73 @@ private void ShowFrameMessage(string message) frameMessageTimer.Start(); } + private void ClearLoadedPdfDocument() + { + _loadedPdfDocument?.Dispose(); + _loadedPdfDocument = null; + _currentPdfPageContent = null; + _currentPdfPageIndex = -1; + MainZoomBorder.RequireSpaceToPan = false; + 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; + + reDrawTimer.Stop(); + ResetGrabFrame(); + await Task.Delay(300); + + _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(); + FreezeToggleButton.Visibility = Visibility.Collapsed; + MainZoomBorder.RequireSpaceToPan = true; + UpdatePdfPageNavigation(); + SwitchToOcrFallbackIfUiAutomation(); + + reDrawTimer.Start(); + } + + 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 +695,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 +709,17 @@ 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 IsPdfPanGestureActive => + IsPdfDocumentLoaded + && MainZoomBorder.CanPan + && !KeyboardExtensions.IsShiftDown() + && !KeyboardExtensions.IsCtrlDown() + && Keyboard.IsKeyDown(Key.Space); + public HistoryInfo AsHistoryItem() { System.Drawing.Bitmap? bitmap = ImageMethods.ImageSourceToBitmap(frameContentImageSource); @@ -1249,12 +1332,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 +1439,13 @@ private void ClearRenderedWordBorders() { RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); + } + + private void ClearRenderedPdfTextLines() + { + PdfTextCanvas.Children.Clear(); + pdfTextLineOverlays.Clear(); } private IReadOnlyCollection? GetUiAutomationExcludedHandles() @@ -1384,6 +1506,28 @@ private void AddRenderedWordBorder(WordBorder wordBorderBox) }); } + private PdfTextLineOverlay CreatePdfTextLineOverlay(Windows.Foundation.Rect sourceRect, double sourceScale, string text, DpiScale dpi) + { + Rect displayRect = new( + sourceRect.X / (dpi.DpiScaleX * sourceScale), + sourceRect.Y / (dpi.DpiScaleY * sourceScale), + sourceRect.Width / (dpi.DpiScaleX * sourceScale), + sourceRect.Height / (dpi.DpiScaleY * sourceScale)); + + 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 +1540,12 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") if (isDrawing || IsDragOver) return; + if (_currentPdfPageContent?.HasNativeText is true) + { + await DrawPdfRectanglesAsync(searchWord); + return; + } + isDrawing = true; IsOcrValid = true; @@ -1524,6 +1674,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) @@ -1719,6 +1934,8 @@ private void Escape_Keyed(object sender, ExecutedRoutedEventArgs e) SearchBox.Text = ""; else if (RectanglesCanvas.Children.Count > 0) ResetGrabFrame(); + else if (PdfTextCanvas.Children.Count > 0) + ResetGrabFrame(); else Close(); } @@ -1790,6 +2007,7 @@ private void SyncRectanglesCanvasSizeToImage() if (double.IsFinite(sourceWidth) && sourceWidth > 0) { GrabFrameImage.Width = sourceWidth; + PdfTextCanvas.Width = sourceWidth; RectanglesCanvas.Width = sourceWidth; TemplateRegionOverlayCanvas.Width = sourceWidth; } @@ -1797,6 +2015,7 @@ private void SyncRectanglesCanvasSizeToImage() if (double.IsFinite(sourceHeight) && sourceHeight > 0) { GrabFrameImage.Height = sourceHeight; + PdfTextCanvas.Height = sourceHeight; RectanglesCanvas.Height = sourceHeight; TemplateRegionOverlayCanvas.Height = sourceHeight; } @@ -1806,6 +2025,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 +2052,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(); } @@ -1967,6 +2194,7 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel FrameText = ""; wordBorders.Clear(); + pdfTextLineOverlays.Clear(); UpdateFrameText(); } @@ -2011,7 +2239,7 @@ private async void GrabFrameWindow_Drop(object sender, DragEventArgs e) frameContentImageSource = null; isStaticImageSource = true; - await TryLoadImageFromPath(fileName); + await TryLoadDocumentFromPath(fileName); IsDragOver = false; @@ -2161,6 +2389,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 +2589,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 +2597,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(); } @@ -2386,6 +2624,7 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul frameContentImageSource = clipboardImage; } + ClearLoadedPdfDocument(); hasLoadedImageSource = true; isStaticImageSource = true; frozenUiAutomationSnapshot = null; @@ -2405,6 +2644,11 @@ 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); + reDrawTimer.Stop(); GrabBTN.Focus(); @@ -2422,13 +2666,17 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) return; } - if (!KeyboardExtensions.IsShiftDown() && !KeyboardExtensions.IsCtrlDown()) + bool shouldPanInsteadOfSelect = IsPdfDocumentLoaded + ? IsPdfPanGestureActive + : !KeyboardExtensions.IsShiftDown() && !KeyboardExtensions.IsCtrlDown() && !isPdfTextInteraction; + + if (shouldPanInsteadOfSelect) return; } isSelecting = true; clickedPoint = e.GetPosition(RectanglesCanvas); - RectanglesCanvas.CaptureMouse(); + interactionSurface.CaptureMouse(); selectBorder.Height = 1; selectBorder.Width = 1; @@ -2439,8 +2687,11 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) e.Handled = true; isMiddleDown = true; - ResetGrabFrame(); - UnfreezeGrabFrame(); + if (!IsPdfDocumentLoaded) + { + ResetGrabFrame(); + UnfreezeGrabFrame(); + } return; } @@ -2460,12 +2711,17 @@ 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); + if (IsCtrlDown) - RectanglesCanvas.Cursor = Cursors.Cross; + interactionSurface.Cursor = Cursors.Cross; else if (MainZoomBorder.CanPan) - RectanglesCanvas.Cursor = Cursors.SizeAll; + interactionSurface.Cursor = IsPdfDocumentLoaded + ? (IsPdfPanGestureActive ? Cursors.SizeAll : Cursors.Arrow) + : (isPdfTextInteraction ? Cursors.Arrow : Cursors.SizeAll); else - RectanglesCanvas.Cursor = null; + interactionSurface.Cursor = null; if (!isSelecting && !isMiddleDown && movingWordBordersDictionary.Count == 0) return; @@ -2473,8 +2729,11 @@ private void RectanglesCanvas_MouseMove(object sender, MouseEventArgs e) isMiddleDown = e.MiddleButton == MouseButtonState.Pressed; if (MainZoomBorder.CanPan - && !KeyboardExtensions.IsShiftDown() - && !KeyboardExtensions.IsCtrlDown()) + && (IsPdfDocumentLoaded + ? IsPdfPanGestureActive + : (!KeyboardExtensions.IsShiftDown() + && !KeyboardExtensions.IsCtrlDown() + && !isPdfTextInteraction))) { isSelecting = false; return; @@ -2522,12 +2781,13 @@ private void RectanglesCanvas_MouseUp(object sender, MouseButtonEventArgs e) { isSelecting = false; CursorClipper.UnClipCursor(); - RectanglesCanvas.ReleaseMouseCapture(); + Mouse.Captured?.ReleaseMouseCapture(); if (e.ChangedButton == MouseButton.Middle && scrollBehavior != ScrollBehavior.Zoom) { isMiddleDown = false; - FreezeGrabFrame(); + if (!IsPdfDocumentLoaded) + FreezeGrabFrame(); reDrawTimer.Start(); return; } @@ -2676,6 +2936,9 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) { foreach (WordBorder wb in wordBorders) wb.Deselect(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Deselect(); MatchesTXTBLK.Text = $"0 Matches"; UpdateFrameText(); return; @@ -2697,6 +2960,9 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) { foreach (WordBorder wb in wordBorders) wb.Deselect(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Deselect(); UpdateFrameText(); MatchesTXTBLK.Text = $"Search Error"; return; @@ -2716,6 +2982,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(); @@ -2796,6 +3073,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() @@ -3209,11 +3491,23 @@ private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = 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(); ResetGrabFrame(); await Task.Delay(300); BitmapImage droppedImage = new(); @@ -3249,6 +3543,28 @@ private async Task TryLoadImageFromPath(string path) } } + private async Task TryLoadPdfFromPath(string path) + { + try + { + _loadedPdfDocument = await PdfDocumentRenderer.LoadAsync(path); + _currentImagePath = Path.GetFullPath(path); + await ShowPdfPageAsync(0); + } + catch (Exception ex) + { + ClearLoadedPdfDocument(); + 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(); @@ -3426,7 +3742,11 @@ private void UndoExecuted(object sender, ExecutedRoutedEventArgs e) private void UnfreezeGrabFrame() { + if (IsPdfDocumentLoaded) + return; + reDrawTimer.Stop(); + ClearLoadedPdfDocument(); hasLoadedImageSource = false; isStaticImageSource = false; frozenUiAutomationSnapshot = null; @@ -3448,16 +3768,63 @@ private void UnfreezeGrabFrame() reDrawTimer.Start(); } - private void UpdateFrameText() + private async void PreviousPdfPageButton_Click(object sender, RoutedEventArgs e) { - string[] selectedWbs = [.. wordBorders - .OrderBy(b => b.Top) - .Where(w => w.IsSelected) - .Select(t => t.Word)]; + 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) + { + 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 = + DefaultSettings.ParagraphDetection + && isSpaceJoining + && 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 @@ -3466,8 +3833,14 @@ private void UpdateFrameText() } 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 AppendWordBordersWithParagraphDetection(stringBuilder); } @@ -3591,9 +3964,12 @@ private void ResetViewMenuItem_Click(object sender, RoutedEventArgs e) 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) @@ -3823,6 +4199,7 @@ private void AutoContrastMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3870,6 +4247,7 @@ private void BrightenMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3917,6 +4295,7 @@ private void DarkenMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3964,6 +4343,7 @@ private void GrayscaleMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); From 3d49a182f81c4b9e31ec0a1dd7f9bb22005a7c6b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 18:39:48 -0500 Subject: [PATCH 19/81] Add PDF support to GrabFrame and update file checks Updated GrabFrame logic to accept both image and PDF files by using IoUtilities.IsVisualDocumentFile. Added PdfPig NuGet package for PDF handling. Improved debug message to reflect support for PDFs. --- Text-Grab/App.xaml.cs | 4 ++-- Text-Grab/Text-Grab.csproj | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 85738b95..d1a32934 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -240,7 +240,7 @@ private static async Task HandleStartupArgs(string[] args) } 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 } } @@ -318,7 +318,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(); diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 16f1fad4..c1512207 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -64,6 +64,7 @@ + From 02fec333a6130bce5c0b7d5129abda1c4edef693 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 25 Apr 2026 18:39:56 -0500 Subject: [PATCH 20/81] Add tests for file type classification and PDF rendering Expanded FilesIoTests to cover file type and filter logic. Added PdfDocumentRendererTests for rendering, coordinate mapping, line grouping, and OCR overlap handling. --- Tests/FilesIoTests.cs | 29 ++++++++++ Tests/PdfDocumentRendererTests.cs | 89 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 Tests/PdfDocumentRendererTests.cs diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 6fbb5403..e8967d06 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -107,4 +107,33 @@ public void GetEditorModeForPath_UsesFileExtension(string path, EtwEditorMode ex { 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); + } } 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); + } +} From 4a0b99a35a846404cf0c223520bee22b4861d635 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 26 Apr 2026 22:47:26 -0500 Subject: [PATCH 21/81] Add "Open File..." option to NotifyIcon context menu Added a new "Open File..." menu item with a document icon to the NotifyIconWindow context menu. Implemented its click handler to asynchronously open the file picker using App.OpenFileWithPickerAsync(). --- Text-Grab/Controls/NotifyIconWindow.xaml | 8 ++++++++ Text-Grab/Controls/NotifyIconWindow.xaml.cs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index bd6e13f7..946db309 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -37,6 +37,14 @@ + + + + + Date: Sun, 26 Apr 2026 22:48:01 -0500 Subject: [PATCH 22/81] Refactor file filters and improve spacebar pan in GrabFrame Introduce FileUtilities.GetOpenDocumentFilter() for unified open file dialog filters, replacing hardcoded strings and supporting images, PDFs, spreadsheets, markdown, and text files. Refactor GrabFrame to generalize and enhance spacebar-based pan/zoom logic, including new state tracking, improved event handling, and better user experience for both images and PDFs. --- Text-Grab/Utilities/FileUtilities.cs | 42 ++++++++++++++-- Text-Grab/Views/EditTextWindow.xaml.cs | 3 +- Text-Grab/Views/GrabFrame.xaml.cs | 70 ++++++++++++++++++++------ 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/Text-Grab/Utilities/FileUtilities.cs b/Text-Grab/Utilities/FileUtilities.cs index cfe0edc7..c84033a9 100644 --- a/Text-Grab/Utilities/FileUtilities.cs +++ b/Text-Grab/Utilities/FileUtilities.cs @@ -38,9 +38,8 @@ public static string GetImageFilter() public static string GetVisualDocumentFilter() { - string imageExtensions = GetImageExtensionsFilterPattern(); - string pdfExtensions = string.Join(";", IoUtilities.PdfExtensions.Select(extension => $"*{extension}")); - string combinedExtensions = string.Join(";", new[] { imageExtensions, pdfExtensions }.Where(pattern => !string.IsNullOrWhiteSpace(pattern))); + string pdfExtensions = GetExtensionsFilterPattern(IoUtilities.PdfExtensions); + string combinedExtensions = GetVisualDocumentFilterPattern(); string imageFilter = GetImageFilter(); return string.Join("|", new[] @@ -51,6 +50,29 @@ public static string GetVisualDocumentFilter() }); } + 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))); + + return string.Join("|", new[] + { + $"Supported documents|{supportedExtensions}", + GetVisualDocumentFilter(), + $"Spreadsheet documents|{spreadsheetExtensions}", + $"Markdown documents|{markdownExtensions}", + "Text documents (*.txt)|*.txt", + "All files (*.*)|*.*" + }); + } + public static string GetPathToLocalFile(string imageRelativePath) { string? executableDirectory = Path.GetDirectoryName(GetExePath()); @@ -116,6 +138,20 @@ private static string GetImageExtensionsFilterPattern() 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); diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 62c12a5e..15f42842 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -50,7 +50,6 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private const double SpreadsheetDefaultColumnWidth = 120; private const double HorizontalWheelScrollStep = 48; private const int WmMouseHWheel = 0x020E; - private const string OpenDocumentFilter = "Supported documents (*.pdf;*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt)|*.pdf;*.csv;*.tsv;*.tab;*.md;*.markdown;*.txt|PDF documents (*.pdf)|*.pdf|Spreadsheet documents (*.csv;*.tsv;*.tab)|*.csv;*.tsv;*.tab|Markdown documents (*.md;*.markdown)|*.md;*.markdown|Text documents (*.txt)|*.txt|All files (*.*)|*.*"; 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 @@ -3084,7 +3083,7 @@ private void OpenFileMenuItem_Click(object sender, RoutedEventArgs e) { // Set filter for file extension and default file extension DefaultExt = ".txt", - Filter = OpenDocumentFilter, + Filter = FileUtilities.GetOpenDocumentFilter(), DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 34aead7e..59cea908 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -68,6 +68,7 @@ public partial class GrabFrame : Window private bool isSearchSelectionOverridden = false; private bool isSelecting; private bool isSpaceJoining = true; + private bool isSpacePanModifierDown = false; private bool isStaticImageSource = false; private readonly Dictionary movingWordBordersDictionary = []; private IOcrLinesWords? ocrResultOfWindow; @@ -554,7 +555,8 @@ private void ClearLoadedPdfDocument() _loadedPdfDocument = null; _currentPdfPageContent = null; _currentPdfPageIndex = -1; - MainZoomBorder.RequireSpaceToPan = false; + SetSpacePanModifierState(false); + UpdateZoomPanMode(); UpdatePdfPageNavigation(); } @@ -590,7 +592,6 @@ private async Task ShowPdfPageAsync(int pageIndex) FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); FreezeToggleButton.Visibility = Visibility.Collapsed; - MainZoomBorder.RequireSpaceToPan = true; UpdatePdfPageNavigation(); SwitchToOcrFallbackIfUiAutomation(); @@ -713,12 +714,31 @@ public static bool CheckKey(VirtualKeyCodes code) private bool IsPdfTextInteraction(object? sender) => ReferenceEquals(sender, PdfTextCanvas); - private bool IsPdfPanGestureActive => - IsPdfDocumentLoaded - && MainZoomBorder.CanPan + private bool IsZoomPanGestureActive => + MainZoomBorder.CanPan && !KeyboardExtensions.IsShiftDown() && !KeyboardExtensions.IsCtrlDown() - && Keyboard.IsKeyDown(Key.Space); + && (!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 UpdateZoomPanMode() + { + MainZoomBorder.RequireSpaceToPan = IsFreezeMode; + + if (!MainZoomBorder.RequireSpaceToPan) + SetSpacePanModifierState(false); + } public HistoryInfo AsHistoryItem() { @@ -1985,6 +2005,7 @@ private void FreezeGrabFrame() Background = new SolidColorBrush(Colors.DimGray); RectanglesBorder.Background.Opacity = 0; IsFreezeMode = true; + UpdateZoomPanMode(); if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) MainZoomBorder.CanZoom = true; @@ -2200,6 +2221,8 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel private void GrabFrameWindow_Deactivated(object? sender, EventArgs e) { + SetSpacePanModifierState(false); + if (!IsWordEditMode && !IsFreezeMode) { ResetGrabFrame(); @@ -2667,8 +2690,8 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) } bool shouldPanInsteadOfSelect = IsPdfDocumentLoaded - ? IsPdfPanGestureActive - : !KeyboardExtensions.IsShiftDown() && !KeyboardExtensions.IsCtrlDown() && !isPdfTextInteraction; + ? IsZoomPanGestureActive + : IsZoomPanGestureActive && !isPdfTextInteraction; if (shouldPanInsteadOfSelect) return; @@ -2717,9 +2740,9 @@ private void RectanglesCanvas_MouseMove(object sender, MouseEventArgs e) if (IsCtrlDown) interactionSurface.Cursor = Cursors.Cross; else if (MainZoomBorder.CanPan) - interactionSurface.Cursor = IsPdfDocumentLoaded - ? (IsPdfPanGestureActive ? Cursors.SizeAll : Cursors.Arrow) - : (isPdfTextInteraction ? Cursors.Arrow : Cursors.SizeAll); + interactionSurface.Cursor = (IsPdfDocumentLoaded || !isPdfTextInteraction) && IsZoomPanGestureActive + ? Cursors.SizeAll + : Cursors.Arrow; else interactionSurface.Cursor = null; @@ -2730,10 +2753,8 @@ private void RectanglesCanvas_MouseMove(object sender, MouseEventArgs e) if (MainZoomBorder.CanPan && (IsPdfDocumentLoaded - ? IsPdfPanGestureActive - : (!KeyboardExtensions.IsShiftDown() - && !KeyboardExtensions.IsCtrlDown() - && !isPdfTextInteraction))) + ? IsZoomPanGestureActive + : (IsZoomPanGestureActive && !isPdfTextInteraction))) { isSelecting = false; return; @@ -3761,6 +3782,7 @@ private void UnfreezeGrabFrame() FreezeToggleButton.Visibility = Visibility.Visible; Background = new SolidColorBrush(Colors.Transparent); IsFreezeMode = false; + UpdateZoomPanMode(); if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) MainZoomBorder.CanZoom = false; @@ -3898,6 +3920,13 @@ private void Window_LocationChanged(object? sender, EventArgs e) private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { + if (e.Key == Key.Space && CanUseSpacePanModifier) + { + SetSpacePanModifierState(true); + e.Handled = true; + return; + } + if (!wasAltHeld && (e.SystemKey == Key.LeftAlt || e.SystemKey == Key.RightAlt)) { RectanglesCanvas.Opacity = 0.1; @@ -3923,6 +3952,17 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e) private void Window_PreviewKeyUp(object sender, KeyEventArgs e) { + if (e.Key == Key.Space) + { + SetSpacePanModifierState(false); + + if (CanUseSpacePanModifier) + { + e.Handled = true; + return; + } + } + if (wasAltHeld && (e.SystemKey == Key.LeftAlt || e.SystemKey == Key.RightAlt)) { RectanglesCanvas.Opacity = 1; From 3ec02aab3cc870678225b75645cfaeca2a6bfec7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 26 Apr 2026 22:48:25 -0500 Subject: [PATCH 23/81] Refactor ZoomBorder panning and event handling Refactored ZoomBorder to use PreviewMouseDown/Up/Move for panning, improving modifier key support and event robustness. Added isPanning state, IsSpacePanModifierPressed, and RequireSpaceToPan for flexible pan activation. Middle mouse now resets zoom/pan. Improved mouse capture/release and removed obsolete handlers. --- Text-Grab/Controls/ZoomBorder.cs | 71 ++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/Text-Grab/Controls/ZoomBorder.cs b/Text-Grab/Controls/ZoomBorder.cs index 198dcb97..004f9dbd 100644 --- a/Text-Grab/Controls/ZoomBorder.cs +++ b/Text-Grab/Controls/ZoomBorder.cs @@ -16,6 +16,7 @@ namespace Text_Grab.Controls; public class ZoomBorder : Border { private UIElement? child = null; + private bool isPanning = false; private Point origin; private Point start; @@ -42,6 +43,8 @@ public override UIElement Child public bool CanZoom { get; set; } = true; + public bool IsSpacePanModifierPressed { get; set; } = false; + public bool RequireSpaceToPan { get; set; } = false; public void Initialize(UIElement element) @@ -58,18 +61,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() @@ -87,10 +81,14 @@ public void Reset() tt.X = 0.0; tt.Y = 0.0; + isPanning = false; + ReleaseMouseCapture(); + Cursor = Cursors.Arrow; CanPan = false; } - private bool IsPanGestureActive() => !RequireSpaceToPan || Keyboard.IsKeyDown(Key.Space); + private bool IsPanGestureActive() => + !RequireSpaceToPan || IsSpacePanModifierPressed || Keyboard.IsKeyDown(Key.Space); private bool BlocksPanFromSource(object? originalSource) { @@ -148,46 +146,66 @@ 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 || !IsPanGestureActive() || BlocksPanFromSource(e.OriginalSource)) + if (e.ChangedButton == MouseButton.Middle) + { + 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); + + if (!CaptureMouse()) + 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 (BlocksPanFromSource(e.OriginalSource)) + 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 || !IsPanGestureActive() || KeyboardExtensions.IsShiftDown() || KeyboardExtensions.IsCtrlDown()) { - child?.ReleaseMouseCapture(); + isPanning = false; + ReleaseMouseCapture(); + Cursor = Cursors.Arrow; return; } @@ -195,5 +213,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; } } From c2407f799bba5d68e20d24fcb02a36084bac4e7f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 26 Apr 2026 22:48:45 -0500 Subject: [PATCH 24/81] Enhance file open and drag-and-drop support Added static helpers for file picker and drag-and-drop file handling in App.xaml.cs. Renamed TryToOpenFile to TryToOpenFilePathAsync and updated usages. Ensured EditTextWindow is activated after opening a file. --- Text-Grab/App.xaml.cs | 49 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index d1a32934..415ec3fa 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Markup; @@ -74,6 +75,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); @@ -265,7 +309,7 @@ private static async Task HandleStartupArgs(string[] args) return true; } - bool openedFile = await TryToOpenFile(currentArgument, isQuiet); + bool openedFile = await TryToOpenFilePathAsync(currentArgument, isQuiet); if (openedFile) return true; @@ -305,7 +349,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; @@ -329,6 +373,7 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) EditTextWindow manipulateTextWindow = new(); manipulateTextWindow.OpenPath(possiblePath); manipulateTextWindow.Show(); + manipulateTextWindow.Activate(); } return true; } From b2a2efa677f707cf407fb490e7d1d22df5d90b23 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 26 Apr 2026 22:48:56 -0500 Subject: [PATCH 25/81] Add unit tests for file dialog filters and drag-drop Expanded FilesIoTests to cover GetOpenDocumentFilter and drag-and-drop file handling. Added tests for document type filters, dropped file path extraction, and drag-drop effects. Included necessary using directives for System.IO and System.Windows. --- Tests/FilesIoTests.cs | 62 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index e8967d06..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; @@ -136,4 +138,62 @@ public void GetVisualDocumentFilter_IncludesPdfSupport() 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)); + } } From 06b256ea96bb4bf4af1a293df03fcda9463e718a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 27 Apr 2026 23:16:46 -0500 Subject: [PATCH 26/81] Improve pan/zoom UX and focus handling in GrabFrame Added a 300ms grace period after releasing Space before disabling pan mode, making panning with Space+mouse smoother. Ensured pan/zoom is always enabled for PDFs and moved focus away from buttons to prevent accidental activation during panning. Refactored event handling and focus logic for more robust and user-friendly pan/zoom interactions. Also improved mouse capture logic and code clarity in ZoomBorder. --- Text-Grab/Controls/ZoomBorder.cs | 10 +++--- Text-Grab/Views/GrabFrame.xaml.cs | 58 +++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/Text-Grab/Controls/ZoomBorder.cs b/Text-Grab/Controls/ZoomBorder.cs index 004f9dbd..e03e9e92 100644 --- a/Text-Grab/Controls/ZoomBorder.cs +++ b/Text-Grab/Controls/ZoomBorder.cs @@ -1,11 +1,11 @@ -using System.Linq; +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 @@ -164,13 +164,16 @@ private void Child_PreviewMouseDown(object sender, MouseButtonEventArgs e) || !CanPan || !IsPanGestureActive() || BlocksPanFromSource(e.OriginalSource)) + { return; + } TranslateTransform tt = GetTranslateTransform(child); start = e.GetPosition(this); origin = new Point(tt.X, tt.Y); - if (!CaptureMouse()) + bool captured = CaptureMouse(); + if (!captured) return; isPanning = true; @@ -199,7 +202,6 @@ private void Child_MouseMove(object sender, MouseEventArgs e) || st.ScaleX == 1.0 || !isPanning || !CanPan - || !IsPanGestureActive() || KeyboardExtensions.IsShiftDown() || KeyboardExtensions.IsCtrlDown()) { diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 59cea908..df18d2eb 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -69,6 +69,7 @@ public partial class GrabFrame : Window 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; @@ -557,6 +558,7 @@ private void ClearLoadedPdfDocument() _currentPdfPageIndex = -1; SetSpacePanModifierState(false); UpdateZoomPanMode(); + SetScrollBehaviorMenuItems(); UpdatePdfPageNavigation(); } @@ -591,6 +593,7 @@ private async Task ShowPdfPageAsync(int pageIndex) _currentPdfPageIndex = pageIndex; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); + MainZoomBorder.CanZoom = true; FreezeToggleButton.Visibility = Visibility.Collapsed; UpdatePdfPageNavigation(); SwitchToOcrFallbackIfUiAutomation(); @@ -732,12 +735,15 @@ private void SetSpacePanModifierState(bool isDown) MainZoomBorder.IsSpacePanModifierPressed = isDown; } - private void UpdateZoomPanMode() + private void MoveKeyboardFocusFromButtonBase() { - MainZoomBorder.RequireSpaceToPan = IsFreezeMode; + if (MainZoomBorder.CanPan && Keyboard.FocusedElement is ButtonBase) + RectanglesCanvas.Focus(); + } - if (!MainZoomBorder.RequireSpaceToPan) - SetSpacePanModifierState(false); + private void UpdateZoomPanMode() + { + MainZoomBorder.RequireSpaceToPan = true; } public HistoryInfo AsHistoryItem() @@ -2221,6 +2227,8 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel private void GrabFrameWindow_Deactivated(object? sender, EventArgs e) { + _spacePanGraceTimer?.Stop(); + _spacePanGraceTimer = null; SetSpacePanModifierState(false); if (!IsWordEditMode && !IsFreezeMode) @@ -2369,6 +2377,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); @@ -2673,7 +2689,8 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) : (GetInteractionSurface(sender) ?? RectanglesCanvas); reDrawTimer.Stop(); - GrabBTN.Focus(); + if (!MainZoomBorder.CanPan) + GrabBTN.Focus(); if (e.RightButton == MouseButtonState.Pressed) { @@ -3920,11 +3937,17 @@ private void Window_LocationChanged(object? sender, EventArgs e) private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { - if (e.Key == Key.Space && CanUseSpacePanModifier) + if (e.Key == Key.Space) { - SetSpacePanModifierState(true); - e.Handled = true; - return; + // 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)) @@ -3954,7 +3977,19 @@ private void Window_PreviewKeyUp(object sender, KeyEventArgs e) { if (e.Key == Key.Space) { - SetSpacePanModifierState(false); + // 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) { @@ -4165,6 +4200,9 @@ private void SetScrollBehaviorMenuItems() default: break; } + + if (IsPdfDocumentLoaded) + MainZoomBorder.CanZoom = true; } private void InvertColorsMI_Click(object sender, RoutedEventArgs e) From 2766e9582eeb6ffa533b3adff809aa7a0bde2781 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 29 Apr 2026 20:20:17 -0500 Subject: [PATCH 27/81] Add clipboard tests --- Tests/ClipboardUtilitiesTests.cs | 101 +++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Tests/ClipboardUtilitiesTests.cs diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs new file mode 100644 index 00000000..5e8896a6 --- /dev/null +++ b/Tests/ClipboardUtilitiesTests.cs @@ -0,0 +1,101 @@ +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]); + } +} From da3ee2d671c1da6487df312c0f5d392c4e376205 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 29 Apr 2026 20:23:53 -0500 Subject: [PATCH 28/81] improve the way the clipboard can handle html table data and more --- Text-Grab/Utilities/ClipboardUtilities.cs | 166 ++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/Text-Grab/Utilities/ClipboardUtilities.cs b/Text-Grab/Utilities/ClipboardUtilities.cs index 833e09ba..804d7231 100644 --- a/Text-Grab/Utilities/ClipboardUtilities.cs +++ b/Text-Grab/Utilities/ClipboardUtilities.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Net; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using System.Windows.Media; @@ -127,6 +130,169 @@ 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]; + int pos = 0; + + while (pos < tableHtml.Length) + { + int rowStart = tableHtml.IndexOf("", rowStart, StringComparison.OrdinalIgnoreCase); + rowEnd = rowEnd >= 0 ? rowEnd + 5 : tableHtml.Length; + + List cells = ParseHtmlRowCells(tableHtml[rowStart..rowEnd]); + if (cells.Count > 0) + result.Add(cells); + + pos = rowEnd; + } + + return result; + } + + private static List ParseHtmlRowCells(string rowHtml) + { + List 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; + + int contentStart = openEnd + 1; + int contentEnd = rowHtml.IndexOf(endTag, contentStart, StringComparison.OrdinalIgnoreCase); + contentEnd = contentEnd >= 0 ? contentEnd : rowHtml.Length; + + cells.Add(CleanHtmlCellContent(rowHtml[contentStart..contentEnd])); + pos = contentEnd + endTag.Length; + } + + return cells; + } + + 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 From a8b61c981b16cd2990eb6d752fd805e98393032f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 29 Apr 2026 20:24:34 -0500 Subject: [PATCH 29/81] send table data to etw properly --- Text-Grab/Views/GrabFrame.xaml.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index df18d2eb..a18f4226 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -3637,10 +3637,12 @@ 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))]; + Point windowPosition = this.GetAbsolutePosition(); DpiScale dpi = VisualTreeHelper.GetDpi(this); System.Drawing.Rectangle rectCanvasSize = new() @@ -3654,8 +3656,6 @@ 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); if (AnalyzedResultTable.TableLines is not null) RectanglesCanvas.Children.Add(AnalyzedResultTable.TableLines); @@ -3664,6 +3664,8 @@ private void TryToPlaceTable() { Debug.WriteLine(ex.Message); } + + return wbInfos; } private void TryToReadBarcodes(DpiScale dpi) @@ -3865,9 +3867,7 @@ [.. wordBorders 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 From 57217937209b3be77dbf6ad653ad3d02345280fb Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 29 Apr 2026 20:25:01 -0500 Subject: [PATCH 30/81] improve perf and options around markdown --- .../Utilities/MarkdownDocumentUtilities.cs | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs index b1d9405a..72c795e6 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -1,5 +1,4 @@ using Markdig; -using Markdig.Extensions.Tables; using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -25,21 +24,23 @@ namespace Text_Grab.Utilities; -public static class MarkdownDocumentUtilities +public static partial class MarkdownDocumentUtilities { - private static readonly Regex LiveBlockTriggerRegex = new( - @"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", - RegexOptions.Compiled); - private static readonly Regex LiveInlinePromotionRegex = new( - @"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", - RegexOptions.Compiled | RegexOptions.Multiline); + 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() .UseAutoLinks() .UsePipeTables() .UseTaskLists() + .UseCitations() + .UseDiagrams() + .UseAlertBlocks() + .UseEmojiAndSmiley() + .UseEmphasisExtras() + .UseAutoIdentifiers() + .UseGridTables() .Build(); private enum MarkdownBlockRole @@ -492,8 +493,8 @@ private static void WriteTable(StringBuilder builder, WpfTable table) if (firstGroup is null || firstGroup.Rows.Count == 0) return; - List rows = firstGroup.Rows.Cast().ToList(); - List headerCells = rows[0].Cells.Cast().Select(SerializeTableCell).ToList(); + List rows = [.. firstGroup.Rows.Cast()]; + List headerCells = [.. rows[0].Cells.Cast().Select(SerializeTableCell)]; builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", headerCells)} |", quotePrefix)); builder.AppendLine(); @@ -506,7 +507,7 @@ private static void WriteTable(StringBuilder builder, WpfTable table) foreach (WpfTableRow row in dataRows) { builder.AppendLine(); - List rowCells = row.Cells.Cast().Select(SerializeTableCell).ToList(); + List rowCells = [.. row.Cells.Cast().Select(SerializeTableCell)]; builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", rowCells)} |", quotePrefix)); } } @@ -834,4 +835,14 @@ private static string GetSourceSlice(string source, MarkdownObject markdownObjec private static string GetCodeFenceInfo(DependencyObject element) => (string)element.GetValue(CodeFenceInfoProperty); private static void SetIsTableHeader(DependencyObject element, bool value) => element.SetValue(IsTableHeaderProperty, value); private static bool GetIsTableHeader(DependencyObject element) => (bool)element.GetValue(IsTableHeaderProperty); + + + [GeneratedRegex(@"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", RegexOptions.Compiled)] + private static partial Regex LiveBlockTrigger(); + + [GeneratedRegex(@"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex MarkdownPattern(); } From 9e8aeb18fa3d35cb17b00a49cd055e329f0053ff Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 29 Apr 2026 20:26:30 -0500 Subject: [PATCH 31/81] enhance the find and replace to be compatible with etw spreadsheet mode --- Text-Grab/Controls/FindAndReplaceWindow.xaml | 14 +- .../Controls/FindAndReplaceWindow.xaml.cs | 185 +++++++++++++++--- Text-Grab/Models/FindResult.cs | 15 +- Text-Grab/Views/EditTextWindow.xaml.cs | 154 ++++++++++++++- 4 files changed, 329 insertions(+), 39 deletions(-) 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..f04bd253 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -59,6 +59,8 @@ public FindAndReplaceWindow() #region Properties + private bool IsSpreadsheetSearch => textEditWindow?.IsSpreadsheetMode is true; + public List FindResults { get; set; } = []; public string StringFromWindow @@ -85,6 +87,8 @@ public EditTextWindow? TextEditWindow public void SearchForText() { + if (IsSpreadsheetSearch) { SearchSpreadsheetCells(); return; } + FindResults.Clear(); ResultsListView.ItemsSource = null; @@ -180,6 +184,61 @@ public void SearchForText() } } + private Regex? BuildCurrentRegex() + { + string rawPattern = FindTextBox.Text; + if (string.IsNullOrEmpty(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); + + RegexOptions options = RegexOptions.None; + if (ExactMatchCheckBox.IsChecked is not true) options |= RegexOptions.IgnoreCase; + if (UsePatternCheckBox.IsChecked is true) options |= RegexOptions.IgnorePatternWhitespace; + + try { return new Regex(rawPattern, options, TimeSpan.FromSeconds(5)); } + catch { return null; } + } + + private void SearchSpreadsheetCells() + { + FindResults.Clear(); + ResultsListView.ItemsSource = null; + Matches = null; + + if (textEditWindow is null || string.IsNullOrWhiteSpace(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 +259,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 +273,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 +295,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 +309,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 +358,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 +369,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; @@ -410,6 +502,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 +518,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 +548,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 +613,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) diff --git a/Text-Grab/Models/FindResult.cs b/Text-Grab/Models/FindResult.cs index 7a083c6e..4b7da09f 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 => Text.Length; + + 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/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 15f42842..e6142772 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1106,6 +1106,15 @@ private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.In return; } + if (e.Key == Key.V + && (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && !IsSpreadsheetCellEditorFocused()) + { + e.Handled = true; + PasteIntoSpreadsheet(); + return; + } + if (e.Key != Key.Enter || SpreadsheetDataGrid.CurrentCell.Column is null) return; @@ -1224,6 +1233,66 @@ internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable } } + 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; + + 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) @@ -1885,6 +1954,7 @@ internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = fal finally { isLoadingOpenedFile = false; + SyncTextFromActiveEditor(); SetOpenedFileState(shouldTrackOpenedFile ? pathOfFileToOpen : null); } } @@ -2359,6 +2429,82 @@ public string GetSelectedTextOrAllText() return textToModify; } + 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, + Text = m.Value.MakeStringSingleLine(), + PreviewLeft = cellValue[previewStart..m.Index], + PreviewRight = cellValue[(m.Index + m.Length)..previewEnd], + 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); + + var 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; + return (RowIndex: row, ColumnIndex: col, Value: pattern.Replace(oldValue, replaceWith)); + }); + + SetSpreadsheetDocumentCellValues(tableDocument, updates); + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + RecordSpreadsheetUndoChange(beforeState, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + } + private IEnumerable GetSelectedOrAllTextSegmentsForEdit() { if (editorMode == EtwEditorMode.Spreadsheet) @@ -3403,7 +3549,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) From 493b304ff065b3d3d2bf97b644865e1ee4536878 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 1 May 2026 23:13:23 -0500 Subject: [PATCH 32/81] update github actions --- .github/workflows/Release.yml | 10 +++++----- .github/workflows/buildDev.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index db3fba66..79f9c39c 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -36,7 +36,7 @@ jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -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..ae14fbb8 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildDev.yml @@ -17,7 +17,7 @@ jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 with: @@ -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 From 2091e938123ebc5e887058335fac7939659858a2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 1 May 2026 23:14:13 -0500 Subject: [PATCH 33/81] remove unused file --- .../AnalysisReport_20260125_220624_678.md | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 .codetesting/AnalysisReport_20260125_220624_678.md 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. - From 38b679f252e35d18b94c3d5788dd477193d4bedf Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 1 May 2026 23:21:40 -0500 Subject: [PATCH 34/81] Simplify Markdig pipeline with UseAdvancedExtensions Replaces individual Markdig extension calls with UseAdvancedExtensions for a cleaner and more maintainable pipeline configuration. This enables a broad set of advanced features in one step. --- Text-Grab/Utilities/MarkdownDocumentUtilities.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs index 72c795e6..0097bc59 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -31,16 +31,7 @@ public static partial class MarkdownDocumentUtilities private static readonly Regex MarkdownPatternRegex = MarkdownPattern(); private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() - .UseAutoLinks() - .UsePipeTables() - .UseTaskLists() - .UseCitations() - .UseDiagrams() - .UseAlertBlocks() - .UseEmojiAndSmiley() - .UseEmphasisExtras() - .UseAutoIdentifiers() - .UseGridTables() + .UseAdvancedExtensions() .Build(); private enum MarkdownBlockRole From 703f09ff85d8ee1e16229140b1a1880948afef0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 15:25:14 +0000 Subject: [PATCH 35/81] Fix review comments: disposal, race conditions, cache eviction, find/replace, colspan/rowspan Agent-Logs-Url: https://github.com/TheJoeFin/Text-Grab/sessions/c5acdc4a-84dd-4662-b5d3-335886f0b193 Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Tests/ClipboardUtilitiesTests.cs | 36 +++++++++ Text-Grab/Utilities/ClipboardUtilities.cs | 86 ++++++++++++++++++++-- Text-Grab/Utilities/OcrUtilities.cs | 4 +- Text-Grab/Utilities/PdfDocumentRenderer.cs | 17 ++++- Text-Grab/Views/EditTextWindow.xaml.cs | 8 +- Text-Grab/Views/GrabFrame.xaml.cs | 58 ++++++++++----- 6 files changed, 180 insertions(+), 29 deletions(-) diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs index 5e8896a6..f88de72c 100644 --- a/Tests/ClipboardUtilitiesTests.cs +++ b/Tests/ClipboardUtilitiesTests.cs @@ -98,4 +98,40 @@ public void ConvertHtmlToTabSeparated_HandlesThElements() 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/Text-Grab/Utilities/ClipboardUtilities.cs b/Text-Grab/Utilities/ClipboardUtilities.cs index 804d7231..e9499b7d 100644 --- a/Text-Grab/Utilities/ClipboardUtilities.cs +++ b/Text-Grab/Utilities/ClipboardUtilities.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; @@ -222,8 +223,11 @@ private static List> ParseHtmlTableToGrid(string html) tableEnd = tableEnd >= 0 ? tableEnd + 8 : html.Length; string tableHtml = html[tableStart..tableEnd]; - int pos = 0; + // 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("> ParseHtmlTableToGrid(string html) int rowEnd = tableHtml.IndexOf("", rowStart, StringComparison.OrdinalIgnoreCase); rowEnd = rowEnd >= 0 ? rowEnd + 5 : tableHtml.Length; - List cells = ParseHtmlRowCells(tableHtml[rowStart..rowEnd]); - if (cells.Count > 0) - result.Add(cells); + 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; } @@ -242,9 +289,9 @@ private static List> ParseHtmlTableToGrid(string html) return result; } - private static List ParseHtmlRowCells(string rowHtml) + private static List<(string Text, int ColSpan, int RowSpan)> ParseHtmlRowCells(string rowHtml) { - List cells = []; + List<(string, int, int)> cells = []; int pos = 0; while (pos < rowHtml.Length) @@ -270,17 +317,42 @@ private static List ParseHtmlRowCells(string rowHtml) 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])); + 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)) diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index aa14648d..4b88756d 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -544,7 +544,7 @@ public static async Task OcrAbsoluteFilePathAsync(string absolutePath, I if (IoUtilities.IsPdfFileExtension(Path.GetExtension(absolutePath))) { - PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(absolutePath); + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(absolutePath); return await pdfDocument.ExtractTextAsync(language); } @@ -666,7 +666,7 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag { if (IoUtilities.IsPdfFileExtension(Path.GetExtension(path))) { - PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(path); + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(path); ocrText = await pdfDocument.ExtractTextAsync(selectedLanguage, grabTemplate); } else diff --git a/Text-Grab/Utilities/PdfDocumentRenderer.cs b/Text-Grab/Utilities/PdfDocumentRenderer.cs index c5bbb47a..74127ff7 100644 --- a/Text-Grab/Utilities/PdfDocumentRenderer.cs +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -66,9 +66,11 @@ public PdfPageTextLine(Windows.Foundation.Rect sourceRect, string text, bool isN 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) { @@ -123,7 +125,11 @@ 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 @@ -135,7 +141,15 @@ public async Task GetPageContentAsync(int pageIndex) 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 @@ -159,7 +173,8 @@ public async Task> GetSelectableLinesAsync(int pa IReadOnlyList imageOcrLines = await GetOcrLinesAsync( pageContent.RenderedPage, resolvedLanguage, - sourceRect => ShouldIncludeOcrLine(sourceRect, pageContent.ImageRegions)); + sourceRect => ShouldIncludeOcrLine(sourceRect, pageContent.ImageRegions) + && !ShouldIncludeOcrLine(sourceRect, pageContent.NativeLines.Select(l => l.SourceRect).ToList())); combinedLines.AddRange(imageOcrLines); return SortLines(combinedLines); diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index e6142772..972b12f2 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -2465,6 +2465,7 @@ public List SearchSpreadsheetCells(Regex pattern) { RowIndex = row, ColumnIndex = col, + Index = m.Index, Text = m.Value.MakeStringSingleLine(), PreviewLeft = cellValue[previewStart..m.Index], PreviewRight = cellValue[(m.Index + m.Length)..previewEnd], @@ -2496,7 +2497,12 @@ public void ReplaceInSpreadsheetCells( 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; - return (RowIndex: row, ColumnIndex: col, Value: pattern.Replace(oldValue, replaceWith)); + + 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); diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a18f4226..15bd1eae 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -98,6 +98,7 @@ public partial class GrabFrame : Window private int translatedWordsCount = 0; private CancellationTokenSource? translationCancellationTokenSource; private readonly List pdfTextLineOverlays = []; + private CancellationTokenSource? _pdfPageNavCts; private const string TargetLanguageMenuHeader = "Target Language"; #endregion Fields @@ -552,6 +553,9 @@ private void ShowFrameMessage(string message) private void ClearLoadedPdfDocument() { + _pdfPageNavCts?.Cancel(); + _pdfPageNavCts?.Dispose(); + _pdfPageNavCts = null; _loadedPdfDocument?.Dispose(); _loadedPdfDocument = null; _currentPdfPageContent = null; @@ -579,26 +583,42 @@ private async Task ShowPdfPageAsync(int pageIndex) if (_loadedPdfDocument is null) return; - reDrawTimer.Stop(); - ResetGrabFrame(); - await Task.Delay(300); + CancellationTokenSource? previousCts = _pdfPageNavCts; + _pdfPageNavCts = new CancellationTokenSource(); + CancellationToken ct = _pdfPageNavCts.Token; + previousCts?.Cancel(); + previousCts?.Dispose(); - _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(); - MainZoomBorder.CanZoom = true; - FreezeToggleButton.Visibility = Visibility.Collapsed; - UpdatePdfPageNavigation(); - SwitchToOcrFallbackIfUiAutomation(); + try + { + reDrawTimer.Stop(); + ResetGrabFrame(); + await Task.Delay(300, ct); - reDrawTimer.Start(); + 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(); + MainZoomBorder.CanZoom = true; + FreezeToggleButton.Visibility = Visibility.Collapsed; + UpdatePdfPageNavigation(); + SwitchToOcrFallbackIfUiAutomation(); + + reDrawTimer.Start(); + } + catch (OperationCanceledException) + { + // Navigation superseded by a newer request — no-op + } } private void UpdatePdfPageNavigation() @@ -2218,6 +2238,7 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel Singleton.Instance.SaveToHistory(this); historyItem?.ClearTransientImage(); + ClearLoadedPdfDocument(); FrameText = ""; wordBorders.Clear(); @@ -3585,6 +3606,7 @@ private async Task TryLoadPdfFromPath(string path) { try { + ClearLoadedPdfDocument(); _loadedPdfDocument = await PdfDocumentRenderer.LoadAsync(path); _currentImagePath = Path.GetFullPath(path); await ShowPdfPageAsync(0); From 5f4772acab45b823464feff44e9d3455def7e13b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 15:28:17 +0000 Subject: [PATCH 36/81] Optimize: materialize native rects once before OCR predicate Agent-Logs-Url: https://github.com/TheJoeFin/Text-Grab/sessions/c5acdc4a-84dd-4662-b5d3-335886f0b193 Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/Utilities/PdfDocumentRenderer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Utilities/PdfDocumentRenderer.cs b/Text-Grab/Utilities/PdfDocumentRenderer.cs index 74127ff7..6e27cd11 100644 --- a/Text-Grab/Utilities/PdfDocumentRenderer.cs +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -170,11 +170,12 @@ public async Task> GetSelectableLinesAsync(int pa 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, pageContent.NativeLines.Select(l => l.SourceRect).ToList())); + && !ShouldIncludeOcrLine(sourceRect, nativeRects)); combinedLines.AddRange(imageOcrLines); return SortLines(combinedLines); From d1cabc51bb3270309bd2d4fa2b20dc2ab89c93c7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 2 May 2026 10:43:19 -0500 Subject: [PATCH 37/81] Support PDFs in folder OCR, improve menu usability Expanded file filtering to include PDFs for OCR, updated UI messages to reference "files" instead of just "images," clarified variable typing in spreadsheet undo logic, and set StaysOpenOnClick for relevant menu items to enhance user experience. --- Text-Grab/Views/EditTextWindow.xaml.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 972b12f2..eb72d0f8 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -259,11 +259,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; } @@ -293,7 +293,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)) { @@ -342,14 +342,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 { @@ -2489,7 +2489,7 @@ public void ReplaceInSpreadsheetCells( SpreadsheetUndoState? beforeState = CreateCurrentSpreadsheetUndoState(syncFromTable: false); - var updates = targets + 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 => @@ -2883,6 +2883,7 @@ private void LoadGrabTemplateMenuItems(MenuItem grabTemplateMenuItem) Header = "(None)", IsCheckable = true, IsChecked = previouslySelected is null, + StaysOpenOnClick = true, }; noneItem.Click += GrabTemplateMenuItem_Click; grabTemplateMenuItem.Items.Add(noneItem); @@ -2895,6 +2896,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); @@ -2995,6 +2997,7 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) Tag = language, IsCheckable = true, IsChecked = i == selectedIndex, + StaysOpenOnClick = true, }; languageMenuItem.Click += LanguageMenuItem_Click; captureMenuItem.Items.Add(languageMenuItem); From fceb621ff75af1f85c71ff7a42f6ce67f5491d0d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 4 May 2026 23:54:10 -0500 Subject: [PATCH 38/81] Add Cut/Copy/Paste support to spreadsheet editor Replaced custom Copy with standard Cut/Copy/Paste commands in the spreadsheet context menu. Added command bindings and keyboard shortcut handling for these actions. Implemented TryCutSpreadsheetCellValues for cut logic with clipboard failure detection. Updated clipboard helper to return success status. Added unit tests for cut scenarios. --- Tests/EditTextWindowSpreadsheetTests.cs | 59 +++++++++++++ Text-Grab/Views/EditTextWindow.xaml | 5 +- Text-Grab/Views/EditTextWindow.xaml.cs | 112 +++++++++++++++++++++--- 3 files changed, 165 insertions(+), 11 deletions(-) diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index 89eadb6b..c8f96203 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -35,6 +35,65 @@ public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells() 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() { diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 69d60201..1172eee6 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -83,7 +83,10 @@ - + + + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index eb72d0f8..dbcc96d3 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -598,15 +598,7 @@ private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) private void CopySpreadsheetSelectionMenuItem_Click(object sender, RoutedEventArgs e) { - List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); - if (selectedCellCoordinates.Count == 0) - return; - - string selectionText = BuildSpreadsheetSelectionText(spreadsheetTable, selectedCellCoordinates); - if (string.IsNullOrEmpty(selectionText)) - return; - - TrySetClipboardText(selectionText); + _ = TryCopySpreadsheetSelectionToClipboard(GetSelectedSpreadsheetCellCoordinates()); } private void AddSpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) @@ -1106,6 +1098,15 @@ private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.In 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()) @@ -1233,6 +1234,23 @@ internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable } } + 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; @@ -1512,6 +1530,24 @@ private void SpreadsheetUndoCanExecute(object sender, CanExecuteRoutedEventArgs 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()) @@ -1551,6 +1587,33 @@ private void SpreadsheetRedoExecuted(object sender, ExecutedRoutedEventArgs e) 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) @@ -1711,17 +1774,43 @@ private void TrackSpreadsheetColumnWidth(DataGridColumn column) DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.AddValueChanged(column, SpreadsheetColumnWidthChanged); } - private void TrySetClipboardText(string text) + 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; @@ -4100,6 +4189,9 @@ 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)); From 16c3daa589949ce70beb4f76d15cea6e71c7bd29 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 9 May 2026 19:57:47 -0500 Subject: [PATCH 39/81] Add Ctrl+C copy support to spreadsheet DataGrid Enable copying selected spreadsheet cells with Ctrl+C in EditTextWindow when the cell editor is not focused. Set ClipboardCopyMode to None in the DataGrid XAML to manage copy behavior programmatically. --- Text-Grab/Views/EditTextWindow.xaml | 1 + Text-Grab/Views/EditTextWindow.xaml.cs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 1172eee6..540dea25 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -758,6 +758,7 @@ AlternatingRowBackground="{DynamicResource ApplicationBackgroundBrush}" AutoGenerateColumns="False" Background="Transparent" + ClipboardCopyMode="None" BeginningEdit="SpreadsheetDataGrid_BeginningEdit" CanUserAddRows="False" CanUserSortColumns="False" diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index dbcc96d3..ebc4b078 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1098,6 +1098,15 @@ private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.In 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()) From d7cc178dd6cb79e77c16e675f56111f81626d32a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 9 May 2026 21:39:55 -0500 Subject: [PATCH 40/81] Add RequiresCopilotPlus to ButtonInfo, update AI actions Added RequiresCopilotPlus property to ButtonInfo to flag actions requiring a Copilot+ PC. Marked AI-powered and translation buttons as Copilot+ dependent. Updated "Enter Markdown Mode" icon to SymbolRegular.Markdown20. --- Text-Grab/Models/ButtonInfo.cs | 58 +++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index dd32483b..a6b9a905 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() { @@ -636,7 +641,7 @@ public static List AllButtons OrderNumber = 6.98, ButtonText = "Enter Markdown Mode", ClickEvent = "EnterMarkdownMode_Click", - SymbolIcon = SymbolRegular.DocumentTextToolbox24 + SymbolIcon = SymbolRegular.Markdown20 }, new() { @@ -769,119 +774,136 @@ public static List AllButtons OrderNumber = 8.1, ButtonText = "Summarize Paragraph", ClickEvent = "SummarizeMenuItem_Click", - SymbolIcon = SymbolRegular.BotSparkle24 + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.2, ButtonText = "Rewrite with Local AI", ClickEvent = "RewriteMenuItem_Click", - SymbolIcon = SymbolRegular.BotSparkle24 + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.3, ButtonText = "Convert to Table", ClickEvent = "ConvertTableMenuItem_Click", - SymbolIcon = SymbolRegular.BotSparkle24 + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.4, ButtonText = "Translate to System Language", ClickEvent = "TranslateToSystemLanguageMenuItem_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.41, ButtonText = "Translate to English", ClickEvent = "TranslateToEnglish_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.42, ButtonText = "Translate to Spanish", ClickEvent = "TranslateToSpanish_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.43, ButtonText = "Translate to French", ClickEvent = "TranslateToFrench_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.44, ButtonText = "Translate to German", ClickEvent = "TranslateToGerman_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.45, ButtonText = "Translate to Italian", ClickEvent = "TranslateToItalian_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.46, ButtonText = "Translate to Portuguese", ClickEvent = "TranslateToPortuguese_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.47, ButtonText = "Translate to Russian", ClickEvent = "TranslateToRussian_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.48, ButtonText = "Translate to Japanese", ClickEvent = "TranslateToJapanese_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.49, ButtonText = "Translate to Chinese (Simplified)", ClickEvent = "TranslateToChineseSimplified_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.5, ButtonText = "Translate to Korean", ClickEvent = "TranslateToKorean_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.51, ButtonText = "Translate to Arabic", ClickEvent = "TranslateToArabic_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.52, ButtonText = "Translate to Hindi", ClickEvent = "TranslateToHindi_Click", - SymbolIcon = SymbolRegular.Translate24 + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true }, new() { OrderNumber = 8.6, ButtonText = "Extract RegEx", ClickEvent = "ExtractRegexMenuItem_Click", - SymbolIcon = SymbolRegular.TextWholeWord20 + SymbolIcon = SymbolRegular.TextWholeWord20, + RequiresCopilotPlus = true }, new() { From d747245e37d7ee05936746997622f1061e8f2bb1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 9 May 2026 21:40:14 -0500 Subject: [PATCH 41/81] Enhance history menu with icons and improved headers Add SymbolIcon-based icons to history menu items reflecting editor mode (Spreadsheet, Markdown, or Text). Update menu item headers to show trimmed, single-line, 40-character text snippets for better readability. --- Text-Grab/Services/HistoryService.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 30cde165..6f2881e2 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -15,6 +15,8 @@ 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; @@ -210,7 +212,17 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) catch { menuItem.IsEnabled = false; } }; - menuItem.Header = $"{history.CaptureDateTime.Humanize()} | {history.TextContent.MakeStringSingleLine().Truncate(20)}"; + 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 + { + EtwEditorMode.Spreadsheet => SymbolRegular.Table24, + EtwEditorMode.Markdown => SymbolRegular.Markdown20, + _ => SymbolRegular.TextT24, + } + }; recentGrabsMenuItem.Items.Add(menuItem); } } From 4a5186ef94e0aab11d65e7aa7fd2ff6053f1bbf0 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 9 May 2026 21:40:27 -0500 Subject: [PATCH 42/81] Enhance recent history menu with icons and longer snippets Recent history menu items in EditTextWindow now show a 40-character text snippet and a SymbolIcon representing the editor mode (table, markdown, or text). Also trims the humanized date for cleaner display. Adds references to Wpf.Ui.Controls.SymbolIcon and SymbolRegular. --- Text-Grab/Views/EditTextWindow.xaml.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index ebc4b078..de4aa4ee 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -37,6 +37,8 @@ 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; @@ -3146,7 +3148,17 @@ private void LoadRecentTextHistory() if (PassedTextControl.Text == history.TextContent) menuItem.IsEnabled = false; - menuItem.Header = $"{history.CaptureDateTime.Humanize()} | {history.TextContent.MakeStringSingleLine().Truncate(20)}"; + 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 + { + EtwEditorMode.Spreadsheet => SymbolRegular.Table24, + EtwEditorMode.Markdown => SymbolRegular.Markdown20, + _ => SymbolRegular.TextT24, + } + }; OpenRecentMenuItem.Items.Add(menuItem); } } From 7451e734b056b1a689f512892aa2dc069eb95483 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 9 May 2026 21:40:37 -0500 Subject: [PATCH 43/81] Add filter box to BottomBarSettings left command list Added a filter TextBox above the left ListView to enable command filtering by text. Replaced the previous layout with a Grid to accommodate the new filter. Implemented filtering logic using a CollectionView and event handler. Removed redundant "Is Icon" labels and improved XAML formatting. --- Text-Grab/Controls/BottomBarSettings.xaml | 137 ++++++++++--------- Text-Grab/Controls/BottomBarSettings.xaml.cs | 21 ++- 2 files changed, 89 insertions(+), 69 deletions(-) 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); From 1418ef47bc3d1da4fedfba3cda460c6260b0ed4e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 13 May 2026 22:36:09 -0500 Subject: [PATCH 44/81] Restore tray icon after Explorer restarts Add support for detecting Windows Explorer restarts by handling the "TaskbarCreated" message. When detected, unregister and re-register the tray icon to restore it. Also, add proper cleanup of the window message hook on window close. Add P/Invoke for RegisterWindowMessage in NativeMethods. --- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 35 +++++++++++++++++++++ Text-Grab/NativeMethods.cs | 4 +++ 2 files changed, 39 insertions(+) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index edaa59d5..ee9dd1f5 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Windows; +using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; using Text_Grab.Models; @@ -17,12 +18,30 @@ namespace Text_Grab.Controls; public partial class NotifyIconWindow : Window { private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private HwndSource? windowSource; public NotifyIconWindow() { InitializeComponent(); } + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + nint windowHandle = new WindowInteropHelper(this).Handle; + windowSource = HwndSource.FromHwnd(windowHandle); + windowSource?.AddHook(NotifyIconWindowMessageHook); + } + + protected override void OnClosed(EventArgs e) + { + windowSource?.RemoveHook(NotifyIconWindowMessageHook); + windowSource = null; + + base.OnClosed(e); + } + private void Exit_Click(object sender, RoutedEventArgs e) { App.Current.Shutdown(); @@ -115,6 +134,22 @@ private void NotifyIcon_IsVisibleChanged(object sender, DependencyPropertyChange NotifyIcon.Visibility = Visibility.Visible; } + private IntPtr NotifyIconWindowMessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if ((uint)msg == NativeMethods.WM_TASKBARCREATED) + RestoreNotifyIconAfterExplorerRestart(); + + return IntPtr.Zero; + } + + private void RestoreNotifyIconAfterExplorerRestart() + { + if (NotifyIcon.IsRegistered) + NotifyIcon.Unregister(); + + NotifyIcon.Register(); + } + private void LastEditWindow_Click(object sender, RoutedEventArgs e) { HistoryInfo? historyInfo = Singleton.Instance.GetEditWindows().LastOrDefault(); diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index ed8beccb..9d3719bf 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); From e45add79560245ca4f603f12f19375045808bea7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 13 May 2026 22:36:25 -0500 Subject: [PATCH 45/81] Expand Bash allow list for bin/pdm CLI commands Added "Bash(bin/pdm *)" to the allow list in settings.local.json, enabling all Bash commands that start with "bin/pdm" for broader pdm CLI support. This complements the existing "Bash(pdm api *)" entry. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6aff92af..434a15b5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,8 @@ "Bash(curl -o bin/pdm https://app.produckmap.com/cli/pdm)", "Bash(chmod +x bin/pdm)", "Bash(bin/pdm ui-element:*)", - "Bash(pdm api *)" + "Bash(pdm api *)", + "Bash(bin/pdm *)" ], "deny": [] } From 4d5975c9f3f54a56b921b507d669caae02a5a379 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 13 May 2026 23:00:21 -0500 Subject: [PATCH 46/81] Add regular settings sidecar support to SettingsService Implement Settings.json sidecar for user settings persistence. Import/export a subset of settings, merge with classic settings, and ensure only allowed properties are handled. Update property change logic to persist changes. Add unit tests for sidecar import, export, and merging behaviors. --- Tests/SettingsServiceTests.cs | 105 +++++++++++++ Text-Grab/Services/SettingsService.cs | 205 ++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) 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/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index fc28f999..c6661df2 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() @@ -505,6 +519,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) From 0d0cf585c13e6e8590da7e23341bb14ef3d2d2a3 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 13 May 2026 23:31:08 -0500 Subject: [PATCH 47/81] Refactor search logic; add TextSearchUtilities class Centralize regex/search logic in new TextSearchUtilities class, updating all usages in FindAndReplaceWindow, EditTextWindow, and GrabFrame for consistency. Improve whitespace and match display handling. Update FindResult to store match length directly. Enhance StringMethods.MakeStringSingleLine for edge cases. Add comprehensive unit tests for new utilities and whitespace scenarios. --- Tests/StringMethodTests.cs | 6 ++ Tests/TextSearchUtilitiesTests.cs | 67 +++++++++++++++++++ .../Controls/FindAndReplaceWindow.xaml.cs | 42 ++++++------ Text-Grab/Models/FindResult.cs | 2 +- Text-Grab/Utilities/StringMethods.cs | 6 ++ Text-Grab/Utilities/TextSearchUtilities.cs | 64 ++++++++++++++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 3 +- Text-Grab/Views/GrabFrame.xaml.cs | 11 ++- 8 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 Tests/TextSearchUtilitiesTests.cs create mode 100644 Text-Grab/Utilities/TextSearchUtilities.cs diff --git a/Tests/StringMethodTests.cs b/Tests/StringMethodTests.cs index 6fb121d1..99d77da1 100644 --- a/Tests/StringMethodTests.cs +++ b/Tests/StringMethodTests.cs @@ -22,6 +22,12 @@ This has Assert.Equal(lineOfText, bodyOfText.MakeStringSingleLine()); } + [Fact] + public void MakeStringSingleLine_NewlineOnly_ReturnsEmptyString() + { + Assert.Equal(string.Empty, Environment.NewLine.MakeStringSingleLine()); + } + [Theory] [InlineData("", "")] [InlineData("is", "This is test string data")] 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/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index f04bd253..825903cb 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -92,6 +92,13 @@ public void SearchForText() 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 @@ -113,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) { @@ -142,7 +141,7 @@ public void SearchForText() return; } - if (Matches.Count < 1 || string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (Matches.Count < 1) { MatchesText.Text = "0 Matches"; return; @@ -160,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); @@ -187,7 +187,7 @@ public void SearchForText() private Regex? BuildCurrentRegex() { string rawPattern = FindTextBox.Text; - if (string.IsNullOrEmpty(rawPattern)) return null; + if (!TextSearchUtilities.HasSearchText(rawPattern)) return null; if (rawPattern.StartsWith('^') && rawPattern.EndsWith('$') && rawPattern.Length > 2) rawPattern = rawPattern[1..^1]; @@ -195,11 +195,7 @@ public void SearchForText() if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) rawPattern = rawPattern.EscapeSpecialRegexChars(matchExactly); - RegexOptions options = RegexOptions.None; - if (ExactMatchCheckBox.IsChecked is not true) options |= RegexOptions.IgnoreCase; - if (UsePatternCheckBox.IsChecked is true) options |= RegexOptions.IgnorePatternWhitespace; - - try { return new Regex(rawPattern, options, TimeSpan.FromSeconds(5)); } + try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, ExactMatchCheckBox.IsChecked is true); } catch { return null; } } @@ -209,7 +205,7 @@ private void SearchSpreadsheetCells() ResultsListView.ItemsSource = null; Matches = null; - if (textEditWindow is null || string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (textEditWindow is null || !TextSearchUtilities.HasSearchText(FindTextBox.Text)) { MatchesText.Text = "0 Matches"; return; @@ -404,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 @@ -466,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; @@ -642,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; @@ -663,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/Models/FindResult.cs b/Text-Grab/Models/FindResult.cs index 4b7da09f..69089d07 100644 --- a/Text-Grab/Models/FindResult.cs +++ b/Text-Grab/Models/FindResult.cs @@ -13,7 +13,7 @@ public class FindResult public string PreviewRight { get; set; } = ""; - public int Length => Text.Length; + public int Length { get; set; } public int? RowIndex { get; set; } diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab/Utilities/StringMethods.cs index f349808a..832f68f3 100644 --- a/Text-Grab/Utilities/StringMethods.cs +++ b/Text-Grab/Utilities/StringMethods.cs @@ -299,9 +299,15 @@ 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); 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/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index de4aa4ee..7624e277 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -2566,9 +2566,10 @@ public List SearchSpreadsheetCells(Regex pattern) RowIndex = row, ColumnIndex = col, Index = m.Index, - Text = m.Value.MakeStringSingleLine(), + Text = TextSearchUtilities.FormatMatchTextForDisplay(m.Value), PreviewLeft = cellValue[previewStart..m.Index], PreviewRight = cellValue[(m.Index + m.Length)..previewEnd], + Length = m.Length, Count = count++ }); } diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 15bd1eae..c9189f02 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1976,7 +1976,7 @@ private void Escape_Keyed(object sender, ExecutedRoutedEventArgs e) 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) ResetGrabFrame(); @@ -2991,7 +2991,7 @@ 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(); @@ -3010,10 +3010,9 @@ 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) { From 436a522cd01cc19a90426d4d7e17112a4d631881 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 14 May 2026 00:10:04 -0500 Subject: [PATCH 48/81] Add per-cell text wrapping support to spreadsheet editor - Track wrapped cells in EditTextTableDocument; add IsCellWrapped/SetCellWrap methods - Update row/column operations to maintain wrapped cell indices - Persist wrapped cell state in serialization and view metrics - Add "Wrap Text" toggle to spreadsheet cell context menu - Bind cell TextWrapping to per-cell wrap state in UI - Add helpers and tests for wrap state and row height persistence - Refactor selection and style logic for new wrapping feature --- Tests/EditTextTableDocumentTests.cs | 25 +++ Tests/EditTextWindowSpreadsheetTests.cs | 73 +++++++ Text-Grab/Models/EditTextTableDocument.cs | 94 +++++++++ Text-Grab/Views/EditTextWindow.xaml | 8 +- Text-Grab/Views/EditTextWindow.xaml.cs | 227 ++++++++++++++++++++-- 5 files changed, 415 insertions(+), 12 deletions(-) diff --git a/Tests/EditTextTableDocumentTests.cs b/Tests/EditTextTableDocumentTests.cs index 3af23bef..d81f228a 100644 --- a/Tests/EditTextTableDocumentTests.cs +++ b/Tests/EditTextTableDocumentTests.cs @@ -78,6 +78,7 @@ public void SerializedJson_RestoresLogicalDimensions() 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); @@ -88,6 +89,7 @@ public void SerializedJson_RestoresLogicalDimensions() 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 _)); } @@ -139,6 +141,7 @@ public void ApplyViewMetricsFrom_PreservesExistingSizing() 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); @@ -148,6 +151,7 @@ public void ApplyViewMetricsFrom_PreservesExistingSizing() Assert.Equal(28, target.RowHeights[0]); Assert.Equal(40, target.RowHeights[1]); Assert.Null(target.RowHeights[2]); + Assert.True(target.IsCellWrapped(1, 1)); } [Fact] @@ -159,6 +163,7 @@ public void Transpose_SwapsRowsAndColumns_AndResetsViewMetrics() minimumColumnCount: 3); document.SetColumnWidth(0, 180); document.SetRowHeight(0, 36); + document.SetCellWrap(0, 2, true); document.Transpose(); @@ -169,5 +174,25 @@ public void Transpose_SwapsRowsAndColumns_AndResetsViewMetrics() 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/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index c8f96203..fc5ea3ad 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -196,4 +196,77 @@ public void SetSpreadsheetDocumentCellValues_SetsOnlyRequestedCells() 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/Text-Grab/Models/EditTextTableDocument.cs b/Text-Grab/Models/EditTextTableDocument.cs index 696dd599..32d46b33 100644 --- a/Text-Grab/Models/EditTextTableDocument.cs +++ b/Text-Grab/Models/EditTextTableDocument.cs @@ -24,6 +24,8 @@ public enum EtwStructuredTextFormat Xml } +public sealed record EditTextTableWrappedCell(int RowIndex, int ColumnIndex); + public sealed class EditTextTableDocument { public const int DefaultMinimumRowCount = 25; @@ -57,6 +59,8 @@ public sealed class EditTextTableDocument public List RowHeights { get; set; } = []; + public List WrappedCells { get; set; } = []; + public static EditTextTableDocument CreateFromText( string? text, int minimumRowCount = DefaultMinimumRowCount, @@ -120,6 +124,10 @@ 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++; @@ -133,6 +141,10 @@ public void InsertColumn(int columnIndex, string? columnName = null) 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) @@ -149,6 +161,11 @@ public void DeleteRow(int rowIndex) 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); @@ -162,6 +179,11 @@ public void DeleteColumn(int columnIndex) 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); @@ -181,6 +203,8 @@ public void MoveRow(int fromIndex, int toIndex) 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); @@ -194,6 +218,8 @@ public void MoveColumn(int fromIndex, int toIndex) 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); @@ -239,6 +265,8 @@ public void Transpose() ColumnNames = BuildGenericColumnNames(Math.Max(1, ColumnCount)); ColumnWidths = []; RowHeights = []; + WrappedCells = [.. WrappedCells + .Select(cell => new EditTextTableWrappedCell(cell.ColumnIndex, cell.RowIndex))]; EnsureMinimumSize(); } @@ -288,6 +316,8 @@ public void EnsureMinimumSize() while (RowHeights.Count > requiredRows) RowHeights.RemoveAt(RowHeights.Count - 1); + + NormalizeWrappedCells(); } public void ApplyViewMetricsFrom(EditTextTableDocument source) @@ -302,6 +332,9 @@ public void ApplyViewMetricsFrom(EditTextTableDocument source) 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) @@ -322,6 +355,38 @@ public void SetRowHeight(int rowIndex, double? height) 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) @@ -340,6 +405,20 @@ private static void MoveListItem(List items, int fromIndex, int toIndex) 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) @@ -348,6 +427,21 @@ private static void MoveListItem(List items, int fromIndex, int toIndex) 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(); diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 540dea25..63cbe017 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -79,7 +79,7 @@ - + @@ -87,6 +87,12 @@ + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 7624e277..58848c77 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -125,6 +125,19 @@ private enum PendingFileCloseAction 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 #region Constructors @@ -444,7 +457,11 @@ private void SetCultureAndLanguageToDefault() Language = xmlDefaultLang; } - private void ApplySpreadsheetDocumentChange(Action changeAction, int? focusRow = null, int? focusColumn = null) + private void ApplySpreadsheetDocumentChange( + Action changeAction, + int? focusRow = null, + int? focusColumn = null, + bool beginEdit = true) { CommitSpreadsheetEditsAndCapturePendingHistory(); SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); @@ -461,7 +478,7 @@ private void ApplySpreadsheetDocumentChange(Action change if (focusRow.HasValue && focusColumn.HasValue) { Dispatcher.BeginInvoke( - () => FocusSpreadsheetCell(focusRow.Value, focusColumn.Value), + () => FocusSpreadsheetCell(focusRow.Value, focusColumn.Value, beginEdit), DispatcherPriority.Background); } } @@ -709,12 +726,7 @@ private void FocusSpreadsheetCell(int rowIndex, int columnIndex, bool beginEdit object rowItem = SpreadsheetDataGrid.Items[rowIndex]; DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; - SpreadsheetDataGrid.ScrollIntoView(rowItem, column); - SpreadsheetDataGrid.SelectedCells.Clear(); - SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); - SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); - UpdateSelectedSpreadsheetCellCoordinates(); - SpreadsheetDataGrid.Focus(); + SelectSpreadsheetCell(rowItem, column, clearExistingSelection: true); if (beginEdit) SpreadsheetDataGrid.BeginEdit(); @@ -819,6 +831,8 @@ private void RebuildSpreadsheetTable() { 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)), }; @@ -1199,6 +1213,19 @@ private void SpreadsheetDataGrid_PreviewMouseRightButtonDown(object sender, Mous 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; } @@ -1434,6 +1461,59 @@ internal static void SetSpreadsheetDocumentCellValues( } } + 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 @@ -1449,6 +1529,21 @@ private void UpdateSelectedSpreadsheetCellCoordinates() 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()); @@ -1656,8 +1751,11 @@ private void SpreadsheetRow_SizeChanged(object sender, SizeChangedEventArgs e) if (rowIndex < 0) return; - double height = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; - tableDocument.SetRowHeight(rowIndex, height); + double? height = GetSpreadsheetPersistedRowHeight(row.Height); + if (!height.HasValue) + return; + + tableDocument.SetRowHeight(rowIndex, height.Value); } private void EditorModeMenuItem_Click(object sender, RoutedEventArgs e) @@ -1766,7 +1864,7 @@ private void CaptureSpreadsheetLayoutMetrics() if (rowIndex < 0) continue; - double height = !double.IsNaN(row.Height) && row.Height > 0 ? row.Height : row.ActualHeight; + double? height = GetSpreadsheetPersistedRowHeight(row.Height); tableDocument.SetRowHeight(rowIndex, height); } } @@ -1850,6 +1948,53 @@ private void UpdateSpreadsheetModeUi() 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, true)); + 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) @@ -1881,6 +2026,25 @@ private void SelectSpreadsheetColumn(int columnIndex) 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(); @@ -5520,6 +5684,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) From 58e30a04dbe02af1bd14d84a69af48f35194ff0d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 14 May 2026 00:31:10 -0500 Subject: [PATCH 49/81] Enable spreadsheet mode for EditTextWindow in table mode Refactored window activation logic to use OpenOrActivateEditTextWindow, which opens EditTextWindow in spreadsheet mode when table mode is selected and no existing window is present. Added ShouldOpenNewEtwInSpreadsheetMode for decision logic and EnterSpreadsheetMode to EditTextWindow. Updated usages in FullscreenGrab and GrabFrame. Added unit tests for spreadsheet mode logic. --- Tests/GrabFrameEtwTests.cs | 23 +++++++++++ Text-Grab/Utilities/WindowUtilities.cs | 39 +++++++++++++++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 2 + .../Views/FullscreenGrab.SelectionStyles.cs | 2 +- Text-Grab/Views/GrabFrame.xaml.cs | 2 +- 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 Tests/GrabFrameEtwTests.cs 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/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/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 58848c77..6024bd64 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -248,6 +248,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; 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/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index c9189f02..497ec80f 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1934,7 +1934,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(); } From 391cb71ed9e236f94edaa975c73f2611166d7e25 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 15 May 2026 23:17:25 -0500 Subject: [PATCH 50/81] Add table edit state and view scale utilities Introduce GrabFrameTableEditState for managing table editing state, separator placement, and normalization in the grab frame. Add GrabFrameViewScaleUtilities for handling scale coercion, window sizing, and scale stepping logic. These utilities support advanced table editing and view scaling features. --- Text-Grab/Models/GrabFrameTableEditState.cs | 176 ++++++++++++++++++ .../Utilities/GrabFrameViewScaleUtilities.cs | 66 +++++++ 2 files changed, 242 insertions(+) create mode 100644 Text-Grab/Models/GrabFrameTableEditState.cs create mode 100644 Text-Grab/Utilities/GrabFrameViewScaleUtilities.cs 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/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)); + } +} From 096f0c2a8deec14d776cac369c52fa1fbd763e70 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 15 May 2026 23:18:02 -0500 Subject: [PATCH 51/81] Add manual table separator support and precision updates Support user-defined row/column separators in table analysis. Switch separator types to double for higher accuracy. Update word-to-cell assignment logic and add helper methods for merging/sanitizing separators. Extend WordBorderInfo with display and formatting properties. --- Text-Grab/Models/HistoryInfo.cs | 6 + Text-Grab/Models/ResultTable.cs | 185 +++++++++++++++-------------- Text-Grab/Models/WordBorderInfo.cs | 11 +- 3 files changed, 115 insertions(+), 87 deletions(-) diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index e70120fb..39e1c095 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -48,6 +48,12 @@ 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)] 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/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; From c6922ff04aa9d807c2923557b6ddb76df86130f2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 15 May 2026 23:18:18 -0500 Subject: [PATCH 52/81] Add manual table editing, paragraph OCR, and zoom support - Enable manual table row/column separator placement in GrabFrame with new UI, overlay, and state persistence - Improve OCR paragraph detection and rendering with grouped lines and enhanced WordBorder support - Add image scaling and zoom controls for frozen images, including overlay scaling and minimum window sizing - Expand test coverage for table editing, paragraph grouping, and view scaling - Fix table line removal, state management, and overlay scaling bugs; refine UI state updates for table editing --- Tests/GrabFrameTableEditStateTests.cs | 31 + Tests/GrabFrameViewScaleUtilitiesTests.cs | 61 ++ Tests/HistoryServiceTests.cs | 14 +- Tests/OcrTests.cs | 21 + Tests/ResultTableManualSeparatorTests.cs | 96 +++ Tests/WordBorderTests.cs | 21 + Text-Grab/Controls/WordBorder.xaml | 2 +- Text-Grab/Controls/WordBorder.xaml.cs | 112 +++- Text-Grab/Controls/ZoomBorder.cs | 50 ++ Text-Grab/Utilities/OcrUtilities.cs | 81 ++- Text-Grab/Views/GrabFrame.xaml | 79 +++ Text-Grab/Views/GrabFrame.xaml.cs | 711 ++++++++++++++++++++-- 12 files changed, 1199 insertions(+), 80 deletions(-) create mode 100644 Tests/GrabFrameTableEditStateTests.cs create mode 100644 Tests/GrabFrameViewScaleUtilitiesTests.cs create mode 100644 Tests/ResultTableManualSeparatorTests.cs create mode 100644 Tests/WordBorderTests.cs 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 28bf8b63..b32d1d26 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -103,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 @@ -121,7 +124,9 @@ await SaveHistoryFileAsync( TextContent = "history with borders", ImagePath = "borders.bmp", SourceMode = TextGrabMode.GrabFrame, - WordBorderInfoJson = inlineWordBorderJson + WordBorderInfoJson = inlineWordBorderJson, + ManualTableColumnSeparators = [44], + ManualTableRowSeparators = [18] } ]); @@ -130,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); diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs index 40993e00..a2e15f79 100644 --- a/Tests/OcrTests.cs +++ b/Tests/OcrTests.cs @@ -222,6 +222,27 @@ public void BuildTextFromOcrLines_UsesParagraphDetectionForWinAi() } } + [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() { 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/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/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..826e7601 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), @@ -41,6 +50,7 @@ private static void OnTemplateIndexChanged(DependencyObject d, DependencyPropert private int contextMenuBaseSize; private SolidColorBrush contrastingForeground = new(Colors.White); private 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) @@ -468,12 +560,18 @@ private void WordBorderControl_MouseDown(object sender, MouseButtonEventArgs e) else Select(); } - private void WordBorderControl_Unloaded(object sender, RoutedEventArgs e) - { - this.MouseDoubleClick -= WordBorderControl_MouseDoubleClick; - this.MouseDown -= WordBorderControl_MouseDown; - this.Unloaded -= WordBorderControl_Unloaded; - } + 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; + } + + 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) { diff --git a/Text-Grab/Controls/ZoomBorder.cs b/Text-Grab/Controls/ZoomBorder.cs index e03e9e92..c4229e97 100644 --- a/Text-Grab/Controls/ZoomBorder.cs +++ b/Text-Grab/Controls/ZoomBorder.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Windows; using System.Windows.Controls; @@ -20,6 +21,13 @@ public class ZoomBorder : Border 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); @@ -47,6 +55,14 @@ public override UIElement Child 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; @@ -87,6 +103,39 @@ public void Reset() 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); @@ -150,6 +199,7 @@ private void Child_PreviewMouseDown(object sender, MouseButtonEventArgs e) { if (e.ChangedButton == MouseButton.Middle) { + ResetRequested?.Invoke(this, EventArgs.Empty); Reset(); e.Handled = true; return; diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 4b88756d..1844cc56 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -445,6 +445,21 @@ private static OcrOutput GetTextFromOcrResult(ILanguage language, Bitmap? scaled 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(); @@ -454,14 +469,18 @@ internal static string BuildTextFromOcrLines(ILanguage language, IOcrLinesWords if (DefaultSettings.ParagraphDetection && isSpaceJoiningOCRLang && lines.Length > 0) { - text.Append(lines[0].Text); - for (int i = 1; i < lines.Length; i++) + List groupedLines = + [ + .. GroupWrappedParagraphLines( + [.. lines.Select((line, index) => new PositionedOcrLine(index, line.Text, line.BoundingBox))]) + ]; + + for (int i = 0; i < groupedLines.Count; i++) { - if (IsWrappedLine(lines[i - 1], lines[i])) - text.Append(' '); - else + if (i > 0) text.AppendLine(); - text.Append(lines[i].Text); + + text.Append(groupedLines[i].SingleLineText); } } else @@ -476,6 +495,56 @@ internal static string BuildTextFromOcrLines(ILanguage language, IOcrLinesWords return text.ToString(); } + internal static List GroupWrappedParagraphLines(IReadOnlyList lines) + { + List groupedLines = []; + + if (lines.Count == 0) + return groupedLines; + + List currentGroup = [lines[0]]; + Windows.Foundation.Rect currentBounds = lines[0].BoundingBox; + + for (int i = 1; i < lines.Count; i++) + { + PositionedOcrLine previousLine = currentGroup[^1]; + PositionedOcrLine currentLine = lines[i]; + + if (IsWrappedParagraph( + previousLine.BoundingBox.Y, + previousLine.BoundingBox.Height, + currentLine.BoundingBox.Y, + currentLine.BoundingBox.Height)) + { + currentGroup.Add(currentLine); + currentBounds = UnionRectangles(currentBounds, currentLine.BoundingBox); + continue; + } + + groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); + currentGroup = [currentLine]; + currentBounds = currentLine.BoundingBox; + } + + groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); + return groupedLines; + } + + private static Windows.Foundation.Rect UnionRectangles(Windows.Foundation.Rect current, Windows.Foundation.Rect next) + { + if (current.IsEmpty) + return next; + + if (next.IsEmpty) + return current; + + double left = Math.Min(current.X, next.X); + double top = Math.Min(current.Y, next.Y); + double right = Math.Max(current.X + current.Width, next.X + next.Width); + double bottom = Math.Max(current.Y + current.Height, next.Y + next.Height); + return new Windows.Foundation.Rect(left, top, right - left, bottom - top); + } + /// /// Determines whether two consecutive lines belong to the same wrapped paragraph /// by comparing the vertical gap between them relative to the average line height. diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 4bdd3675..2e01e043 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -311,6 +311,21 @@ IsChecked="{Binding IsChecked, ElementName=TableToggleButton, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + - /// 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/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 06d8a091..cfdc1fbc 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -12,7 +12,7 @@ Text_Grab.App app.manifest x64;x86;ARM64 - Joseph Finney 2025 + Joseph Finney 2026 TextGrab.net git https://github.com/TheJoeFin/Text-Grab From 0c782eb476832d70aa1b541e3d34b167cda8655c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 16 May 2026 23:34:47 -0500 Subject: [PATCH 55/81] Refresh managed JSON settings after import Added ReconcileManagedJsonSettings to SettingsService to invalidate and reload all managed JSON settings from disk. Updated import workflow to call this method after importing settings from a ZIP file, ensuring in-memory cache is consistent with imported files. --- Text-Grab/Services/SettingsService.cs | 9 +++++++++ Text-Grab/Utilities/SettingsImportExportUtilities.cs | 2 ++ 2 files changed, 11 insertions(+) diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index c6661df2..3f7d0a0b 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -311,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); 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 From 59fbbd82450ee6d845e45a0b21a8cc2f569e1c19 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 16 May 2026 23:36:01 -0500 Subject: [PATCH 56/81] Update app version to 4.14.0 Bump version from 4.13.2 to 4.14.0 in Package.appxmanifest and Text-Grab.csproj to reflect the new release. No other changes included. --- Text-Grab-Package/Package.appxmanifest | 2 +- Text-Grab/Text-Grab.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index b3fe9a6f..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 diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index cfdc1fbc..07b97563 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.13.2 + 4.14.0 $(NoWarn);WFO0003 From 28c4d2536112adc8a6a43610a66d0bef1d301fe9 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 16 May 2026 23:51:27 -0500 Subject: [PATCH 57/81] Update docs to .NET 10.0, refactor startup args, docs - Migrate project, docs, and configs from .NET 9.0 to .NET 10.0 - Require Visual Studio 2022 and .NET 10.0 SDK in build instructions - Refactor App.xaml.cs startup argument parsing for clarity and robustness, add StartupArguments record, improve flag handling, and add unit tests - Update GitHub Actions to use global.json for SDK versioning - Revise README and copilot-instructions.md for accuracy, grammar, and new features - Update launch.json for .NET 10.0 output - Correct .csproj metadata and links - Minor content and formatting improvements throughout documentation --- .github/copilot-instructions.md | 16 +++--- .github/workflows/Release.yml | 2 +- .github/workflows/buildDev.yml | 2 +- .vscode/launch.json | 4 +- README.md | 63 +++++++++++++--------- Tests/StartupTests.cs | 31 ++++++++++- Tests/Tests.csproj | 3 -- Text-Grab/App.xaml.cs | 96 ++++++++++++++++++--------------- Text-Grab/Text-Grab.csproj | 2 +- 9 files changed, 134 insertions(+), 85 deletions(-) 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 79f9c39c..b51c1e06 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -41,7 +41,7 @@ jobs: - 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 }} diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml index ae14fbb8..a72cfec5 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildDev.yml @@ -21,7 +21,7 @@ jobs: - 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 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/README.md b/README.md index d04fa79f..c3c6f9ba 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. 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/Tests.csproj b/Tests/Tests.csproj index a6f350f3..8b25f2c1 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -59,9 +59,6 @@ PreserveNewest - - PreserveNewest - diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 415ec3fa..57191061 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -2,15 +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; @@ -20,7 +17,6 @@ using Text_Grab.Views; using Wpf.Ui; using Wpf.Ui.Appearance; -using Wpf.Ui.Extensions; namespace Text_Grab; @@ -29,6 +25,12 @@ 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; @@ -227,58 +229,63 @@ 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 { - 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}"); - } - } + string absolutePath = Path.GetFullPath(arg); + if (File.Exists(absolutePath)) + grabFramePath = absolutePath; } + catch (Exception ex) + { + 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; } @@ -289,12 +296,17 @@ private static async Task HandleStartupArgs(string[] args) } } - 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(); @@ -309,7 +321,7 @@ private static async Task HandleStartupArgs(string[] args) return true; } - bool openedFile = await TryToOpenFilePathAsync(currentArgument, isQuiet); + bool openedFile = await TryToOpenFilePathAsync(currentArgument, startupArguments.IsQuiet); if (openedFile) return true; diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 07b97563..8e71c9bd 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -13,7 +13,7 @@ app.manifest x64;x86;ARM64 Joseph Finney 2026 - TextGrab.net + https://textgrab.net git https://github.com/TheJoeFin/Text-Grab README.md From 27e4a37498fdf46bf2e37901611d78cdaf563682 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 00:15:00 -0500 Subject: [PATCH 58/81] Remove Moq package from test project dependencies The Moq package reference was removed from Tests.csproj. No other changes were made to the project file or dependencies. --- Tests/Tests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 8b25f2c1..ef24db19 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -18,7 +18,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - runtime; build; native; contentfiles; analyzers; buildtransitive From ba1b619c2f9c0995592a1f80322993b0158b55e2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 11:05:58 -0500 Subject: [PATCH 59/81] Add BUILT-WITH.md and third-party license notices Added BUILT-WITH.md with full NuGet dependency inventory and license info. Updated README to reference it. Added Markdig BSD-2-Clause, Microsoft Visual Studio BenchmarkDotNetDiagnosers, and WindowsAppSDK license texts to satisfy redistribution and notice requirements. --- BUILT-WITH.md | 39 ++++++++ README.md | 2 + .../licenses/Markdig-license.txt | 23 +++++ ...csHub.BenchmarkDotNetDiagnosers-LICENSE.md | 94 +++++++++++++++++++ .../Microsoft.WindowsAppSDK-license.txt | 91 ++++++++++++++++++ 5 files changed, 249 insertions(+) create mode 100644 BUILT-WITH.md create mode 100644 ThirdPartyNotices/licenses/Markdig-license.txt create mode 100644 ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md create mode 100644 ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt 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 c3c6f9ba..71b91fc0 100644 --- a/README.md +++ b/README.md @@ -141,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/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. From c0c1eaad54d6b465301fb48feba3d7a091c1b0f8 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 11:06:11 -0500 Subject: [PATCH 60/81] Add Licenses window and third-party package catalog Introduces a Licenses window listing all direct NuGet dependencies, their licenses, and links to license notices and project URLs. Adds ThirdPartyNoticeUtilities and ThirdPartyPackageInfo for centralized package metadata and notice handling. Updates project file to include BUILT-WITH.md and license files in output/package. Adds unit tests to validate the package catalog. Updates FirstRunWindow UI to provide access to the new Licenses window. --- Tests/ThirdPartyNoticeUtilitiesTests.cs | 71 +++++++++ Text-Grab/Models/ThirdPartyPackageInfo.cs | 14 ++ Text-Grab/Text-Grab.csproj | 12 ++ .../Utilities/ThirdPartyNoticeUtilities.cs | 90 ++++++++++++ Text-Grab/Views/FirstRunWindow.xaml | 10 ++ Text-Grab/Views/FirstRunWindow.xaml.cs | 5 + Text-Grab/Views/LicensesWindow.xaml | 136 ++++++++++++++++++ Text-Grab/Views/LicensesWindow.xaml.cs | 41 ++++++ 8 files changed, 379 insertions(+) create mode 100644 Tests/ThirdPartyNoticeUtilitiesTests.cs create mode 100644 Text-Grab/Models/ThirdPartyPackageInfo.cs create mode 100644 Text-Grab/Utilities/ThirdPartyNoticeUtilities.cs create mode 100644 Text-Grab/Views/LicensesWindow.xaml create mode 100644 Text-Grab/Views/LicensesWindow.xaml.cs 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/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/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 8e71c9bd..5edefbf5 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -46,6 +46,18 @@ True \ + + BUILT-WITH.md + True + \ + PreserveNewest + + + ThirdPartyNotices\licenses\%(RecursiveDir)%(Filename)%(Extension) + True + ThirdPartyNotices\licenses\%(RecursiveDir) + PreserveNewest + True \ 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/Views/FirstRunWindow.xaml b/Text-Grab/Views/FirstRunWindow.xaml index b6bf5ebb..47631b05 100644 --- a/Text-Grab/Views/FirstRunWindow.xaml +++ b/Text-Grab/Views/FirstRunWindow.xaml @@ -431,9 +431,19 @@ Joe
+ + (); + } + private async void StartupCheckbox_Checked(object sender, RoutedEventArgs e) { if (sender is ToggleSwitch toggleSwitch && toggleSwitch.IsChecked is not null) 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); + } +} From 2fa52400c676def8073adbe4e1bfcd7ca130bf25 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 11:44:33 -0500 Subject: [PATCH 61/81] Redesign FirstRunWindow onboarding experience Complete visual and UX overhaul of the FirstRunWindow for a modern, card-based onboarding. Each mode is now presented as an interactive card with previews and live launch, and quick setup options are surfaced as styled toggles. Added new XAML styles and accent resources. Code-behind now uses a settingsInitialized flag to prevent premature event handling, refactors default mode logic, and improves type safety with the TextGrabMode enum. Enhances user guidance, discoverability, and code maintainability. --- Text-Grab/Views/FirstRunWindow.xaml | 1311 ++++++++++++++++-------- Text-Grab/Views/FirstRunWindow.xaml.cs | 38 +- 2 files changed, 914 insertions(+), 435 deletions(-) diff --git a/Text-Grab/Views/FirstRunWindow.xaml b/Text-Grab/Views/FirstRunWindow.xaml index 47631b05..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,443 +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 or PDF onto the Grab Frame to view it 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 67206ed9..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(); } @@ -148,6 +155,9 @@ private void LicensesButton_Click(object sender, RoutedEventArgs e) 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; @@ -177,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) { @@ -187,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; + } } From fb5a92d2c8d5aa52bb6ec6012aa1006d8153f4df Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 11:44:54 -0500 Subject: [PATCH 62/81] Update NuGet package versions in main and test projects Upgraded dependencies in Text-Grab.csproj, including Magick.NET, Markdig, Microsoft.WindowsAppSDK, and WPF-UI packages. Also updated Microsoft.NET.Test.Sdk to 18.5.1 in Tests.csproj. These updates ensure compatibility and bring in the latest features and fixes. --- Tests/Tests.csproj | 2 +- Text-Grab/Text-Grab.csproj | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index ef24db19..ae3b476a 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -17,7 +17,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 5edefbf5..8b678034 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -68,20 +68,20 @@ - - - - + + + + - - - - + + + + - - + + From d9cf0bd2dfd6b75d9b481121f86443df12f147cc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 17 May 2026 12:09:28 -0500 Subject: [PATCH 63/81] Update NotifyIcon window management and visibility Update NotifyIconWindow to use Loaded event, hide from taskbar, and set WindowStyle to None for a more hidden tray icon. Refactor NotifyIconUtilities to reuse existing NotifyIconWindow instances, prevent duplicates, and ensure proper cleanup and recreation. Improves tray icon reliability and non-intrusiveness. --- Text-Grab/Controls/NotifyIconWindow.xaml | 7 +++--- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 2 +- Text-Grab/Utilities/NotifyIconUtilities.cs | 28 ++++++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index 946db309..406c8d5e 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -10,14 +10,15 @@ Title="NotifyIconWindow" Width="0" Height="0" - Activated="Window_Activated" + Loaded="Window_Loaded" Background="Transparent" IsHitTestVisible="False" Left="-50" Opacity="0" - ShowInTaskbar="True" + ShowActivated="False" + ShowInTaskbar="False" Top="-50" - WindowStyle="ToolWindow" + WindowStyle="None" mc:Ignorable="d"> (); + 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(); + } } From 9749f5382a573d0236c654249358f816185c4309 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 18 May 2026 23:11:19 -0500 Subject: [PATCH 64/81] Hide NotifyIconWindow from Alt+Tab and taskbar Make NotifyIconWindow fully invisible and non-interactive by setting window styles and properties to hide it from Alt+Tab and the taskbar. Add Win32 interop for style changes. Simplify clipboard image type check. --- Text-Grab/Controls/NotifyIconWindow.xaml | 3 ++- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 11 ++++++++++- Text-Grab/NativeMethods.cs | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index 406c8d5e..10ee70fe 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -10,14 +10,15 @@ Title="NotifyIconWindow" Width="0" Height="0" - Loaded="Window_Loaded" Background="Transparent" IsHitTestVisible="False" Left="-50" + Loaded="Window_Loaded" Opacity="0" ShowActivated="False" ShowInTaskbar="False" Top="-50" + Visibility="Hidden" WindowStyle="None" mc:Ignorable="d"> diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 1d64d8f4..2160ee93 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -11,6 +11,7 @@ using Text_Grab.Services; using Text_Grab.Utilities; using Text_Grab.Views; +using System.Runtime.InteropServices; using Wpf.Ui.Tray.Controls; namespace Text_Grab.Controls; @@ -56,6 +57,7 @@ private void NotifyIcon_LeftClick(NotifyIcon sender, RoutedEventArgs e) private void Window_Loaded(object sender, RoutedEventArgs e) { Hide(); + HideFromAltTab(); NotifyIcon.Visibility = Visibility.Visible; string toolTipText = "Text Grab"; @@ -134,6 +136,13 @@ private void NotifyIcon_IsVisibleChanged(object sender, DependencyPropertyChange NotifyIcon.Visibility = Visibility.Visible; } + private void HideFromAltTab() + { + IntPtr handle = new WindowInteropHelper(this).Handle; + int exStyle = NativeMethods.GetWindowLong(handle, NativeMethods.GWL_EX_STYLE); + NativeMethods.SetWindowLong(handle, NativeMethods.GWL_EX_STYLE, (exStyle | NativeMethods.WS_EX_TOOLWINDOW) & ~NativeMethods.WS_EX_APPWINDOW); + } + private IntPtr NotifyIconWindowMessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if ((uint)msg == NativeMethods.WM_TASKBARCREATED) @@ -191,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/NativeMethods.cs b/Text-Grab/NativeMethods.cs index 9d3719bf..7ac8315d 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -29,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); } From c7103d0e0e9a1ca34a8010c37e29bac417919998 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 18 May 2026 23:12:26 -0500 Subject: [PATCH 65/81] Add setting to normalize line endings on paste Introduces EtwNormalizeLineEndingsOnPaste (default: True) to control whether pasted text in the Edit Text Window is converted to Windows (CRLF) line endings. Updates settings UI with a checkbox and description, and ensures the setting is loaded and saved when toggled. --- Text-Grab/App.config | 3 +++ Text-Grab/Pages/EditTextWindowSettings.xaml | 11 +++++++++++ Text-Grab/Pages/EditTextWindowSettings.xaml.cs | 9 +++++++++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++++++- Text-Grab/Properties/Settings.settings | 5 ++++- 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index d2fc2c4c..f2601b5a 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -193,6 +193,9 @@ False + + True + 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." /> + + + False + + True + @@ -240,4 +243,4 @@ True - \ No newline at end of file + From 71255d64134ec485735c4d4a78351b66f6dc7d6a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 19 May 2026 00:20:47 -0500 Subject: [PATCH 66/81] normalize line endings on paste, and simplify the enter behavior of spreadsheet mode on the ETW --- Text-Grab/Views/EditTextWindow.xaml.cs | 31 +++++++++----------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index f8950b36..7e94f55d 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1143,26 +1143,6 @@ private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.In return; } - if (e.Key != Key.Enter || SpreadsheetDataGrid.CurrentCell.Column is null) - return; - - int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); - int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column.DisplayIndex; - int lastLogicalRowIndex = (tableDocument?.RowCount ?? spreadsheetTable.Rows.Count) - 1; - - if (currentRowIndex != lastLogicalRowIndex) - return; - - _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Cell, true); - _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Row, true); - SyncSpreadsheetDocumentFromTable(); - - e.Handled = true; - int insertRowIndex = lastLogicalRowIndex + 1; - ApplySpreadsheetDocumentChange( - document => document.InsertRow(insertRowIndex), - insertRowIndex, - currentColumnIndex); } private void SpreadsheetDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) @@ -1308,6 +1288,9 @@ private void PasteIntoSpreadsheet() 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); @@ -1971,7 +1954,7 @@ 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, true)); + 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; } @@ -2230,8 +2213,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; From afd69ac4ebb5260f06b4f0582f3a5774d9594f05 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 19 May 2026 19:45:12 -0500 Subject: [PATCH 67/81] Split up the paragraph detection on the grab frame to balance against table detection --- Tests/OcrTests.cs | 25 +++++++++++++++++++++ Text-Grab/Utilities/OcrUtilities.cs | 9 ++++++-- Text-Grab/Views/GrabFrame.xaml.cs | 34 ++++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs index a2e15f79..996488be 100644 --- a/Tests/OcrTests.cs +++ b/Tests/OcrTests.cs @@ -222,6 +222,31 @@ public void BuildTextFromOcrLines_UsesParagraphDetectionForWinAi() } } + [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() { diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 8d5079a8..6ca9befd 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -376,7 +376,7 @@ public static async Task> GetTextFromRandomAccessStream(IRandomA public static async Task> GetTextFromWinAiAsync(Bitmap bitmap, WindowsAiLang language) { - if (DefaultSettings.ParagraphDetection && language.IsSpaceJoining()) + if (ShouldUseParagraphDetection(language.IsSpaceJoining())) { WinAiOcrLinesWords? ocrResult = await WindowsAiUtilities.GetOcrResultAsync(bitmap); if (ocrResult is not null) @@ -474,7 +474,7 @@ internal static string BuildTextFromOcrLines(ILanguage language, IOcrLinesWords bool isSpaceJoiningOCRLang = language.IsSpaceJoining(); IOcrLine[] lines = ocrResult.Lines; - if (DefaultSettings.ParagraphDetection && isSpaceJoiningOCRLang && lines.Length > 0) + if (ShouldUseParagraphDetection(isSpaceJoiningOCRLang) && lines.Length > 0) { List groupedLines = [ @@ -502,6 +502,11 @@ .. GroupWrappedParagraphLines( 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 = []; diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 02e63b6b..64a39581 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1938,7 +1938,7 @@ private IReadOnlyList CreateOcrBorderRenderInfos(DpiScale d positionedLines.Add(new OcrUtilities.PositionedOcrLine(i, GetNormalizedOcrLineText(ocrLine), ocrLine.BoundingBox)); } - if (!(DefaultSettings.ParagraphDetection && isSpaceJoining)) + if (!IsParagraphDetectionActive()) { return [ @@ -4090,10 +4090,18 @@ 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(); } @@ -4430,8 +4438,7 @@ [.. lines (double Top, double Left, double Height, string Text, bool AllowParagraphJoin) currentLine = orderedLines[i]; bool shouldJoinParagraph = - DefaultSettings.ParagraphDetection - && isSpaceJoining + IsParagraphDetectionActive() && previousLine.AllowParagraphJoin && currentLine.AllowParagraphJoin && OcrUtilities.IsWrappedParagraph(previousLine.Top, previousLine.Height, currentLine.Top, currentLine.Height); @@ -4501,8 +4508,7 @@ private void AppendWordBordersWithParagraphDetection(StringBuilder sb) { WordBorder prev = sorted[i - 1]; WordBorder curr = sorted[i]; - if (DefaultSettings.ParagraphDetection - && isSpaceJoining + if (IsParagraphDetectionActive() && OcrUtilities.IsWrappedParagraph(prev.Top, prev.Height, curr.Top, curr.Height)) sb.Append(' '); else @@ -4511,6 +4517,22 @@ private void AppendWordBordersWithParagraphDetection(StringBuilder sb) } } + 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(); From 3df3d6a06271cb6ede826cae5ff0c87a479f4ae7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 19 May 2026 20:03:24 -0500 Subject: [PATCH 68/81] Update labels and help for calc/date math features Renamed "Show Math Errors" to "Show Calc Errors" for consistency. Expanded EditTextWindow help to include date math support and durations as a unit type. Added new example calculations for date and duration math. No functional changes to DataGrid properties. --- Text-Grab/Models/ButtonInfo.cs | 2 +- Text-Grab/Views/EditTextWindow.xaml | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index a6b9a905..0ea8341f 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -646,7 +646,7 @@ public static List AllButtons new() { OrderNumber = 7.1, - ButtonText = "Toggle Show Math Errors", + ButtonText = "Toggle Show Calc Errors", ClickEvent = "ToggleShowMathErrors_Click", SymbolIcon = SymbolRegular.MathSymbols24 }, diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 63cbe017..a62442e4 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -486,7 +486,7 @@ - + + + + - + + + Date: Tue, 19 May 2026 20:03:53 -0500 Subject: [PATCH 69/81] Add duration conversion and target unit support to date math Support expressions like "years to days" and date diffs in weeks. Refactor CalculationService to parse, convert, and return numeric results for duration math. Add new helpers, regex patterns, and unit tests for duration conversions and targeted date subtraction. Use fixed averages for months/years. --- Tests/CalculatorTests.cs | 35 +++ Tests/UnitConversionTests.cs | 25 +++ .../CalculationService.DateTimeMath.cs | 207 +++++++++++++++++- Text-Grab/Services/CalculationService.cs | 13 +- 4 files changed, 268 insertions(+), 12 deletions(-) diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index edeec63e..77357fec 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -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/UnitConversionTests.cs b/Tests/UnitConversionTests.cs index e8d116a0..ab759c3b 100644 --- a/Tests/UnitConversionTests.cs +++ b/Tests/UnitConversionTests.cs @@ -72,6 +72,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() { 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.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)) { From b6ffae6fe36aa53b0583f0d2be727a6ca855300f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 19 May 2026 20:17:25 -0500 Subject: [PATCH 70/81] Add pace unit support and pace/speed conversion logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded unit conversion to handle runner pace units (min/mi, min/km) and conversions between pace and speed (e.g., min/mi ↔ mph, km/h ↔ min/km). Introduced PaceUnit enum, updated unit parsing to support time-based values, and refactored conversion logic for mixed pace/speed scenarios. Added comprehensive tests and allowed "km/hr" as a speed unit alias. --- Tests/UnitConversionTests.cs | 50 +++ .../Services/CalculationService.UnitMath.cs | 284 +++++++++++++----- 2 files changed, 258 insertions(+), 76 deletions(-) diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs index ab759c3b..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); @@ -165,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 @@ -246,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); @@ -259,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); @@ -302,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")] @@ -399,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/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(); /// From 91a213dbbc4f4a90832c76dc7180fcd2e40d1860 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 17:16:54 -0500 Subject: [PATCH 71/81] Add JoinLines and ShuffleLines string methods with tests Added JoinLines and ShuffleLines to StringMethods.cs for advanced line joining and shuffling with custom options. Included thorough unit tests covering edge cases and deterministic shuffling. Added null argument validation for robustness. --- Tests/StringMethodTests.cs | 76 +++++++++++++++++++++++++++- Text-Grab/Utilities/StringMethods.cs | 45 ++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/Tests/StringMethodTests.cs b/Tests/StringMethodTests.cs index 72de4c75..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() { @@ -28,6 +44,36 @@ 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")] @@ -168,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("", "")] diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab/Utilities/StringMethods.cs index 832f68f3..c82ba643 100644 --- a/Text-Grab/Utilities/StringMethods.cs +++ b/Text-Grab/Utilities/StringMethods.cs @@ -314,6 +314,26 @@ public static string MakeStringSingleLine(this string textToEdit) 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; @@ -736,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)); From c6674dce7298a0b19e9974f35e45ee7cf10eadc3 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 17:17:15 -0500 Subject: [PATCH 72/81] Add JoinLinesWindow for advanced line joining UI Introduces JoinLinesWindow with Wpf.Ui FluentWindow, allowing users to join lines with custom options (joining text, trimming, prefix/suffix). Features a debounced live preview, efficient handling of large/multi-line samples, segment sampling, truncation, and command bindings for Apply/Done actions. --- Text-Grab/Controls/JoinLinesWindow.xaml | 155 +++++++++++ Text-Grab/Controls/JoinLinesWindow.xaml.cs | 309 +++++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 Text-Grab/Controls/JoinLinesWindow.xaml create mode 100644 Text-Grab/Controls/JoinLinesWindow.xaml.cs 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); +} From 195b071c79fa124516b2cf0b5da021bc5017814f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 17:17:38 -0500 Subject: [PATCH 73/81] Add Shuffle and Join Lines features to EditTextWindow Added "Shuffle Lines" and "Join Lines..." menu items to EditTextWindow context menus. Implemented handlers and logic for shuffling and joining lines, including a JoinLinesWindow dialog for user input. Added support for previewing selected or all text segments, with spreadsheet mode awareness. --- Text-Grab/Views/EditTextWindow.xaml | 9 +++++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 27 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index a62442e4..51341fe4 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -68,6 +68,7 @@ Click="MoveLineDownMenuItem_Click" Header="Move Selection Down" InputGestureText="Alt + Down" /> + @@ -259,6 +260,10 @@ x:Name="SingleLineMenuItem" Command="{x:Static local:EditTextWindow.SingleLineCmd}" Header="Make _Single Line" /> + + text.JoinLines(joiningText, trimLineBeforeJoining, textAtBeginning, textAtEnd)); + } + public void AddThisText(string textToAdd) { PassedTextControl.AppendText(textToAdd); @@ -2336,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) @@ -2684,6 +2698,14 @@ 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() @@ -3960,6 +3982,11 @@ private void RemoveDuplicateLines_Click(object sender, RoutedEventArgs e) PassedTextControl.Text = PassedTextControl.Text.RemoveDuplicateLines(); } + private void ShuffleLinesMenuItem_Click(object sender, RoutedEventArgs e) + { + ApplySelectedTextOrAllTextTransform(text => text.ShuffleLines()); + } + private void ReplaceReservedCharsCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() From f99d3e4e421f81e1715aab5bf092c1b0d5ff03d6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 23:57:07 -0500 Subject: [PATCH 74/81] Implement IDisposable for WindowResizer, update P/Invoke Refactored WindowResizer to implement IDisposable for proper cleanup of event handlers and Win32 message hooks. Switched P/Invoke attributes from DllImport to LibraryImport for source generation. Improved resource management and code style, including use of default literals. --- Text-Grab/Utilities/WindowResizer.cs | 49 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) 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 From c70253f00f6224fdac97fac9ba394c2dc3314e84 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 23:57:26 -0500 Subject: [PATCH 75/81] Refactor HistoryService: IDisposable, cleanup, menu handling Implemented IDisposable for HistoryService to ensure proper cleanup of timers, event handlers, and cached resources. Improved bitmap caching to prevent leaks. Added ClearRecentGrabsMenuItems to detach handlers and clear items safely. Centralized recent grab menu item click logic for maintainability. Added _disposed guard to prevent double disposal. --- Text-Grab/Services/HistoryService.cs | 80 +++++++++++++++++++++------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 6f2881e2..6b882cfe 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -20,7 +20,7 @@ namespace Text_Grab.Services; -public class HistoryService +public partial class HistoryService : IDisposable { #region Fields @@ -43,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 @@ -70,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() @@ -179,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) { @@ -187,6 +192,8 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) return; } + recentGrabsMenuItem.IsEnabled = true; + string historyBasePath = await FileUtilities.GetPathToHistory(); foreach (HistoryInfo history in grabsHistory) @@ -195,22 +202,8 @@ 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); - - if (selectedHistory is null) - { - menuItem.IsEnabled = false; - return; - } - - GrabFrame grabFrame = new(selectedHistory); - try { grabFrame.Show(); } - catch { menuItem.IsEnabled = false; } - }; + MenuItem menuItem = new() { Tag = history.ID }; + menuItem.Click += RecentGrabMenuItem_Click; string snippet = history.TextContent.Trim().Replace("\t", " ").MakeStringSingleLine().Truncate(40); menuItem.Header = $"{history.CaptureDateTime.Humanize().Trim()} | {snippet}"; @@ -227,6 +220,33 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) } } + 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) @@ -454,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 From a289764c72d3dd0f13331448afa29546a638e0af Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 23:57:41 -0500 Subject: [PATCH 76/81] Refactor event handling and cleanup in Find/WordBorder Remove unused usings and improve TextEditWindow event subscription in FindAndReplaceWindow. Refactor WordBorder for better immutability, cleanup, and error handling: use readonly for debounceTimer, simplify context menu logic, improve translation method structure, and enhance resource cleanup on unload. --- .../Controls/FindAndReplaceWindow.xaml.cs | 4 +- Text-Grab/Controls/WordBorder.xaml.cs | 124 +++++++++--------- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index 825903cb..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; @@ -74,6 +72,8 @@ public EditTextWindow? TextEditWindow get => textEditWindow; set { + textEditWindow?.PassedTextControl.TextChanged -= EditTextBoxChanged; + textEditWindow = value; textEditWindow?.PassedTextControl.TextChanged += EditTextBoxChanged; diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 826e7601..ef7c9394 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -49,7 +49,7 @@ 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); @@ -407,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)) @@ -426,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 }); @@ -567,72 +565,74 @@ private void WordBorderControl_Unloaded(object sender, RoutedEventArgs e) 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; + private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(Word)) + return; - if (!WindowsAiUtilities.CanDeviceUseWinAI()) + if (!WindowsAiUtilities.CanDeviceUseWinAI()) + { + await new Wpf.Ui.Controls.MessageBox { - await new Wpf.Ui.Controls.MessageBox - { - Title = "Translation Not Available", - Content = "Windows AI is not available on this device.", - CloseButtonText = "OK" - }.ShowDialogAsync(); - return; - } + Title = "Translation Not Available", + Content = "Windows AI is not available on this device.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return; + } - // Store original text - string originalWord = Word; + // Store original text + string originalWord = Word; - 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) + 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) { - 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 +} From f2f43d9d9543a1f604a3d17f046327b21fc6d08b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 23:58:16 -0500 Subject: [PATCH 77/81] Add explicit cleanup for windows and resource disposal Improves memory/resource management by adding cleanup methods to App, EditTextWindow, FullscreenGrab, and GrabFrame. Detaches event handlers, stops timers, disposes resources, and clears references on window close/unload. Refactors theme registry monitoring and menu item event handling. Removes unused usings. Enhances stability and prevents memory leaks. --- Text-Grab/App.xaml.cs | 38 ++++++++- Text-Grab/Views/EditTextWindow.xaml.cs | 109 +++++++++++++++++++------ Text-Grab/Views/FullscreenGrab.xaml.cs | 20 ++++- Text-Grab/Views/GrabFrame.xaml.cs | 41 +++++++++- 4 files changed, 175 insertions(+), 33 deletions(-) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 57191061..f84baa00 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -34,6 +34,8 @@ internal readonly record struct StartupArguments( #region Fields private static readonly Settings _defaultSettings = AppUtilities.TextGrabSettings; + private static RegistryMonitor? _themeRegistryMonitor; + private static RegistryKey? _themeRegistryKey; #endregion Fields @@ -208,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)) @@ -393,7 +415,15 @@ public static async Task TryToOpenFilePathAsync(string possiblePath, bool 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/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 2f2cc59a..4eb53d1f 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -3287,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) { @@ -3295,33 +3295,12 @@ 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); - - 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(); - }; + MenuItem menuItem = new() { Tag = history.ID }; + menuItem.Click += RecentTextHistoryMenuItem_Click; if (PassedTextControl.Text == history.TextContent) menuItem.IsEnabled = false; @@ -3341,6 +3320,71 @@ private void LoadRecentTextHistory() } } + private void RecentTextHistoryMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem || menuItem.Tag is not string historyId) + return; + + 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) { if (string.IsNullOrWhiteSpace(GetSelectedTextOrAllText())) @@ -5107,6 +5151,19 @@ private void Window_Closed(object sender, EventArgs e) 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; 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.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 64a39581..a608d213 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -105,6 +105,8 @@ public partial class GrabFrame : Window private bool isLoadedVisualDocument = false; private double frozenFrameContentScale = 1; private const string TargetLanguageMenuHeader = "Target Language"; + private WindowResizer? windowResizer; + private bool _isCleanedUp; #endregion Fields @@ -533,7 +535,7 @@ private void StandardInitialize() SetRestoreState(); - WindowResizer resizer = new(this); + windowResizer = new WindowResizer(this); reDrawTimer.Interval = new(0, 0, 0, 0, 500); reDrawTimer.Tick += ReDrawTimer_Tick; @@ -1332,6 +1334,15 @@ 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; @@ -1349,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; @@ -1384,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() @@ -4536,6 +4574,7 @@ private bool ShouldRefreshOcrBordersForTableModeActivation() private void Window_Closed(object? sender, EventArgs e) { SetGrabFrameUserSettings(); + CleanupGrabFrame(); WindowUtilities.ShouldShutDown(); } From 002194208ef3310a67681d7859308428222e14cc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 20 May 2026 23:58:49 -0500 Subject: [PATCH 78/81] Allow Bash directory listing commands in allowed list Added Bash commands for listing directories and selecting names in "D:/source/TheJoeFin/Text-Grab/Text-Grab/" to the allowed commands list in settings.local.json. No other changes were made. --- .claude/settings.local.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 434a15b5..332a4bb8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,9 @@ "Bash(chmod +x bin/pdm)", "Bash(bin/pdm ui-element:*)", "Bash(pdm api *)", - "Bash(bin/pdm *)" + "Bash(bin/pdm *)", + "Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)", + "Bash(Select-Object Name)" ], "deny": [] } From dfafa8a426923c90013b1fd5ca66ea45d64c2fac Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 21 May 2026 00:21:03 -0500 Subject: [PATCH 79/81] Allow PowerShell dotnet build commands in settings Added PowerShell(dotnet build *) to the allowed commands list in settings.local.json to enable running dotnet build via PowerShell. No other changes were made. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 332a4bb8..e7e464a3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,8 @@ "Bash(pdm api *)", "Bash(bin/pdm *)", "Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)", - "Bash(Select-Object Name)" + "Bash(Select-Object Name)", + "PowerShell(dotnet build *)" ], "deny": [] } From d7c15bc76fc4800b599a55b6a0ba489ed0406a68 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 21 May 2026 00:23:18 -0500 Subject: [PATCH 80/81] Add "Join Lines" and "Shuffle Lines" buttons Added two new buttons to ButtonInfo: "Join Lines..." (Merge24 icon, JoinLinesMenuItem_Click event, order 1.31) and "Shuffle Lines" (ArrowShuffle24 icon, ShuffleLinesMenuItem_Click event, order 3.51). --- Text-Grab/Models/ButtonInfo.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 0ea8341f..b2ce5dbf 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -229,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", @@ -357,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", From a59a281e68cd9b81525175f36a1d94df205ef2a0 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 23 May 2026 16:21:59 -0500 Subject: [PATCH 81/81] Update NuGet package versions in project files Bump several dependencies to latest versions in Tests.csproj and Text-Grab.csproj, including Magick.NET, Microsoft.WindowsAppSDK, and coverlet.collector. No code or logic changes included. --- Tests/Tests.csproj | 2 +- Text-Grab/Text-Grab.csproj | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index ae3b476a..e7955cb9 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 8b678034..f039a5ef 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -68,15 +68,15 @@ - - - + + + - - - - + + + +