From 2e23fd09a128db5980c45535efef8ade873f3081 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 20 Oct 2026 23:59:14 -0300 Subject: [PATCH] feat: paste images and files from the clipboard, more image formats, scripts without boxes, code font without ligatures arboard (image-data) approved for clipboard paste: image bytes become a PNG attachment, copied files inside the vault are linked without copying, outside files are copied; works in Visual and Source. image crate gains jpeg, gif, webp, bmp decoders. Lazy system-font fallbacks for 28 scripts (Bengali, Indic, Thai, CJK, Khmer...) and the math font as last resort. JetBrains Mono copy with calt emptied (SIL OFL, renamed). Big embedded images are shrunk to the texture limit off the UI thread (fixes a crash). Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- ATTRIBUTION.md | 1 + Cargo.lock | 32 ++ Cargo.toml | 2 + assets/fonts/JetBrainsMono-NoLigatures.ttf | Bin 0 -> 269984 bytes crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/src/design/fonts/mod.rs | 217 ++++++++++ crates/ui-egui/src/design/fonts/scripts.rs | 76 ++++ crates/ui-egui/src/design/fonts/system.rs | 28 ++ crates/ui-egui/src/design/fonts/tests.rs | 160 ++++++++ .../ui-egui/src/shell/attachment_drop/big.rs | 40 ++ .../src/shell/attachment_drop/clipboard.rs | 241 +++++++++++ .../shell/attachment_drop/clipboard/tests.rs | 373 ++++++++++++++++++ .../src/shell/attachment_drop/images.rs | 286 ++++++++++---- .../ui-egui/src/shell/attachment_drop/mod.rs | 271 ++++++++++--- .../src/shell/attachment_drop/overlay.rs | 14 +- .../src/shell/attachment_drop/tests.rs | 137 ++++++- crates/ui-egui/src/text/features/paste.rs | 46 +++ crates/ui-egui/tests/code_font.rs | 26 ++ crates/ui-egui/tests/paste_clipboard.rs | 185 +++++++++ 20 files changed, 1992 insertions(+), 149 deletions(-) create mode 100644 assets/fonts/JetBrainsMono-NoLigatures.ttf create mode 100644 crates/ui-egui/src/design/fonts/mod.rs create mode 100644 crates/ui-egui/src/design/fonts/scripts.rs create mode 100644 crates/ui-egui/src/design/fonts/system.rs create mode 100644 crates/ui-egui/src/design/fonts/tests.rs create mode 100644 crates/ui-egui/src/shell/attachment_drop/big.rs create mode 100644 crates/ui-egui/src/shell/attachment_drop/clipboard.rs create mode 100644 crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs create mode 100644 crates/ui-egui/src/text/features/paste.rs create mode 100644 crates/ui-egui/tests/code_font.rs create mode 100644 crates/ui-egui/tests/paste_clipboard.rs diff --git a/AGENTS.md b/AGENTS.md index f16cbcf..41624a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ NotesCraft is a native, Obsidian-compatible notes app written in **Rust only** ( - One crate per concern in `crates/`, package `notescraft-`, `[lints] workspace = true`, registered in the root `[workspace.dependencies]` and in `xtask/src/layers.rs`. - **Layering is enforced** by `cargo xtask layers`: a crate may depend only on strictly lower layers; nothing below L5 may use egui, eframe, winit or rfd. Layers: L0 `vault` `markdown`; L1 `index` `canvas` `diagrams` `whiteboard`; L2 `backlinks` `graph` `templates`; L3 `plugins` `themes`; L4 `engine`; L5 `editor` `mcp` `i18n`; L6 `ui-egui`. - Shared crates come from craft-libs (`craft-atomic`, `craft-settings`, `craft-ui`, `craft-quit`, `craft-print`), pinned by git rev in the root `Cargo.toml`. Do not change their API; ask first. -- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`. +- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`, and for paste (2026-10-20): `arboard` (image-data), `image` (png feature only). ## Never crash (outranks features) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d254a91..0f65082 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,6 +4,7 @@ |---|---|---|---|---| | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, `assets/fonts/OFL-Inter.txt` | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono (monospace) | The JetBrains Mono Project Authors | | SIL OFL 1.1, `assets/fonts/OFL-JetBrainsMono.txt` | +| `assets/fonts/JetBrainsMono-NoLigatures.ttf` | JetBrains Mono, modified copy used for code (the `calt` feature is emptied so `!=` and `->` stay as typed; renamed `JetBrains Mono NoLigatures`) | The JetBrains Mono Project Authors | | SIL OFL 1.1, `assets/fonts/OFL-JetBrainsMono.txt` | | `assets/fonts/STIXTwoMath-Regular.ttf` | STIX Two Math 2.13, subset to the math Unicode blocks (math type) | STIX Fonts Project | (tag 2.13) | SIL OFL 1.1, `assets/fonts/OFL-STIXTwoMath.txt` | | `assets/icons/*.svg` | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`) | ISC, `assets/icons/LICENSE-lucide.txt` | diff --git a/Cargo.lock b/Cargo.lock index 59b3bcb..99fbc37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,6 +752,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -1671,6 +1677,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2139,10 +2155,25 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2990,6 +3021,7 @@ dependencies = [ name = "notescraft-ui-egui" version = "0.1.0" dependencies = [ + "arboard", "axum", "craft-ui", "egui", diff --git a/Cargo.toml b/Cargo.toml index 2ae2dcc..0fda399 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,8 @@ muda = { version = "=0.21.2", default-features = false } egui = { version = "0.36", default-features = false } unicode-bidi = { version = "0.3.18", default-features = false, features = ["std", "hardcoded-data"] } egui_extras = { version = "0.36", default-features = false, features = ["svg"] } +# Clipboard images and file lists for paste (owner-approved 2026-10-20; already in the lockfile through egui-winit). image-data only: no default features. +arboard = { version = "3.6", default-features = false, features = ["image-data"] } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" log = "0.4" diff --git a/assets/fonts/JetBrainsMono-NoLigatures.ttf b/assets/fonts/JetBrainsMono-NoLigatures.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4f945a7a6aff77b2d360335cf49760d1a3fa8068 GIT binary patch literal 269984 zcmcG%4V+ce{>T4ad#|;do=a0Elhc_wGfiqrdeXzln3?RC!C z#E3|9{;|?*U`77{Imfn|D&gH(BEhJEBaS-89r68x65caLD&ZFt{Z627of zWbCG+jybq%*Jm2F6fyI$QaxeHxY{#fWdV6}M9RiYIB#masIut+B4<>RzWC(YQ>KhR zIJ}gyQ$?CII%V8BwZu20K18xknS8;?kK6Cg6q!9)f|ol_nmDc|e?r@Pu-}ICo|A}> z9t{gP9>sCNq$yLUwY$H30LME;a$}RvoG>n{_LWT{n2_AbQ^rlJZTfb01?kg>h@UZT z%EWuuygXOJVGBgu+S)VEnObo|y+R2`P7)b5zxM2jwX^@YASN0eR6pJQ&dcBT-cuXww%2hONsuZ*bt2)eJ?kl4`F-!bCj`5F zIW6P=q9-*^^qyHEXUNrBGME>BD_C*WO&Uo^stqd5TyfZ#9cb?j^=eL%uBMb&k^0`& zLL2PePrm<=m=cW{QWlTPDrvlTulK=TXXeJm-2KsCDb+4`k1TPCusIsK{{Kh}+scVB z=D*N@bfxD1fU>j=ZQod^{12#aTCeJ<&^nEV|0i@JzmjR^<%G`#)ePB$2Z8$aYy2la z|F>xg>QvNDdr&{shY_G{uZ#NlYiOB6e}!Lb%l~DZ&Qa~VU!v{EwCg|VvE-{uW{m4< zrv2JK<^Mm@7Tb#UJi(j+VlR7hVtgdQ5YubO~mo~L6NBjnvzN)ML>3&kj_TC>E z+g~9y_W9KJOX}Vi)E_-B>hj;(rRjC*|6hl-&3YcGFOS1Bpl#Rrsn?RsHD6Wdv|ej6 zZP0sfcgXmoeXL}IUU#*;&PBDANz=MAecPF^Ui&nCD3qkpV};{P-%1aT)u!4R4w-wJ z-m5srQ)PRl!$+f4kQq;H+gQ+Zp{~#BoBFPGXYQRDpEEwH?Q%E{wC=0ZXn$y)Tu{48 zQ_!*0e2QKl6z#K&@0u@@uJvnwWX8M;VYR90+QyPJdaUi$W3}a=ALx0j=d)g;>Yl@y z{?Kc$hPC_&kg+$Iu#Vv`eb!^`53O7Em+P2}pK4S6%*+oBt3CB0(|_u()~jjScD1K| z)}0U9@7jJHr_5Z?IqsHm=PQw{rGVNAhGiZ7SwUM!>^{O9A7tlGOeRBY)UF|z9N{W+cC%normg^+E6mr8dW6|mq}MU>Z=}W*-W~g zUmDhSt6!P(P3zIH<{bu_rftx$#${}j5*`U6zoC+7iIiPced9bH@Q2FBqp4g5=AK;j|xO+EY?&TTX)13H5koNS>pDF@lm>v! zy%)d%gy(?HcO5$oYdy~ZzNhl*Ii0bs<@NXs(7Lpqn?TbQtyANbX`o}Tb~b?K&7kel zI8}|)^Ry7umXZy6Zha5x*ZrV*)jzHayWfFV=|gO5c+Z1`Nl(oQofn(w!$t5~Q2E2_ z>F{5Ek*@{Eamww*27R=r17ZACGIOEsT-5N7_mHm<`UE--u7S7G$6B6vW!DsJd`*~f zuZ!mYk$(D~IlTKT!i?RX`Q$$zKQH0s0m}Pa0#ir#G|!t#b`&^uv$Lt=%7io)7dzYJO?I3ve!_V2%r&*A=<%_Txo7G8)%utdd$-kz&*cBVR;}wKP`esdA0GzwgSnQv zNBo-Kn!oa=K1j`3ox^3&7K)%LX#17cp!SYTA8VYZ>$#$HTh9aLVhZh(0iddN(-$cl z?NL29;%W5!eg(9wqTx(^ztB|wcPIVV=tsPwb}5(2do*F{Vn3~N@9Xdo$9nCkNkjWn z<1>=uI(2DW24UW%*0Snn#;*3!Z!wy*!9H)QZ*)$a2=udbgv=U^Ih&ea+An%d()zUy z&8K;^?+%2!K*x!Bol?$+RGRjq_H{1Yoen!RGxxBS>I1cTKX6{Au4lKRI#;zmwX5T! z^ITQSBvI`nwXJznHJ@q*=0M7}&IL_VbWHI%6|e17w0tJ4?NMK}y&A7RWa2bF6Q}WY z(X>oCtxxB-&J!KOy7}scHJ{qnJgS+talWVKqRz*RU+TNIU;9(XO8wI@)G^b(&a^qB z>Zj6#w*H7tgN2Ye_f^%Gzd_pXRG*&1vD#48IyGEp9;V7HCr8R>Xc ztuvEH+fF#+%RW_mnosLX=TD_+Ii1_-@_XN}_cHm$uZ0pT*Z1 z8%_If=s4BQ^RuzNh5D3V@n6em{LjkPInJc*(~K=uJx_jdyziPud?u`#$(M=K<6jG> z@~$C2WYRM#Km2=qDsHHcuUn>WIAv4sD{5D5=ymlMb!3j~ma7}i`0#70HdA$^HIpZG zo@sq`;_HS}Wz?^9o|IpiaGmyN()Zb|V=oi;TUxiDGkG#?%^YVm6<^18U0>_ksoO6< z%kzVXuC-G4>5tKiIW7b~LE19}=0abQz13*jG~LlLpsHLbvU|JNy*a3s)%26$To_0C zYVr?@UfC)) z$@wxT#d36x{865ve|}2SRG)Ic94hzAXgO87?tNFE5>tQ2%CRZ6{2KzFH}dJIG})Q@ zTuzTeX^=X$P1f^a-vU0$t0xPju^cSD)^Z4hK<6e;eKImmd2j>sXKQez*{_*(}^Uukjo_|gLZTa`-FVA0*|9t-H{8#h; zkpE`>y8KP~|4g_aPG+f8YAX1lA~-BZ+}sAbXqMLmnoDwR7q)-i=S}8NX-m-V^qoxOey7y^LUEKK1M(ePp3LW^PE0&O-CJ`J?&Nd|`H& zT|rLJH9b1dFgmX?I`5`NN5W{|=oE%M!>NqUOh)JW@W$}I@ZoT2_=oTvYitvnYYS{& zTVap3C)v67c6+aV%syqGweQ*uc8lG?=*(br7N$q11*4Ob9-Xf7-tm%n1*0=HH99lm zkNjeETIRK`Gdi>L=H}hW=-iukf8Ims(OJjne8}j0#^^|X6GkV8(MjYN<#*pVIs^BO z&e;6Z@-NK4JpcOqJ5ri_9&YJY-e3b|iS&UAbL_unFdM2)6bnakuek_oJM4=Sk zT)43C;lgEwZx*g+bUv#yIt%xW&KZo(f}-CuI-eGOlNz0?86C&yEZmr^J33(}?s-cY zqtsteviGV?_$S@7dC#If3po*+b5j13v#zh{rK#cBVQn}m92ZuH$H)nLU*y{GC9K_} zSFLrK^1EN>I!63&{eAbX$j4uaZ0@X{KTeLQnudah<0q|Dzf>&P0wzczv)gw1)Cn&G-tCnkp6em*iA>JO5BA9`)|tK z#5G<2K3!Q2i{ z1^t5K!kub0{5ISf{$O*s9<{MC<~;WW{j=?Uf>5#Ly8ZJ%+lBZ(8mIqqrVO_uE#JJb zm%w8Cf?aR_!nyLH-Dp3xTkRKioBfyFMH)E08mIr<5+88S62H>DoQeN;&E!nyzt=5t zPyIZXdl(zZbovsvnxnPwChVI%?$4Pr?oIce+vc{rJrOg5Z*UYu?fEy|15x{^b4077 zUQxePYQN}Jj@G~z(e`>>>!o`n>$t4h8H@j}S!Zip*3^Gb`DIMlgKN;*@J{Q|J1D=ChvgAT%2IhYe4Kkw(sd3Wj-HZNG$)#K&3Wb`Gu>PnK5DNCA9J0!ZvM``#ptf%YB<|o9xb<5m_LWF zxnesrdeL6xI)r&)e)P2KYcC6Lj#hnZ$km43{q#$(^hMZjoDMnanW9$zSC)c}q6PJMzAKAe-eo z`MZ22JIsEjp=n~8%IBuo^f3pUu4bU=%+X)LFhL*!;tEq9ocWxkmpx0xEb-At6jOduDU3hB$GqqTe})6D?6 z)*LH0nlZA%ToJt$y%{|dJr}Kvo{XN4UW#6bRz&AT=SLStmqeFF)1z~vsnLw+;%HiQ zQFK;xPH=8m5}X&DA504_3@!>51k;0ygL{MffIH2eaGkF?-EU^F3e7+HAh$F2CNqWBy|PYTjp6_M!R6Y&0L6P39}}53}9; z)BMYPZN4+#vR2y_m>^(9&^kDPRa@I29^?m!pdcvZ`%_(6#dQyQ1U*?T^bPt1y@S%A ze=s1(3kC*lg2TB^9ubrUzO}G6M0*j%6e%ee`1CDI^QFEgO%u7$zsjx$4m z^0jo7?Q)c9AxE0#a;E7mXPLfos_7=Dn;tUR^pq*)P&v(XmvhWva;`aC&NHQQzBxjs zntrmtoFaFbQ)Qt!UG6nghQ(z_u)(7 z%i*f7t!|#X+1=vi+b8TYS8G3Zee5@GitFNf+HdWbu9f}DO>!;mc6X@V?0UI8 zXI-WJ$o00L+0R{xJHu{pL)~ERD?40M*U@FWA+FH=%N^h*x`SMM7ddA?aQnMlm*d*F z)-JH$xq_`_S%jBkez2=+5Hqw#44-7T6cVr^9E%72$LC zHap+mVsCYKvaWyJcCn}1(e`-m#ACQm_Odt+TOO-PPB*GW9(V1Q779WwuddV!|WM$Aou#i z?YVY}J(Z`5YI}q|(+;wyaBuEv2iUv5QLJ~Lv(Ixcf5!gazRDW-K6}5tnDy`__F-1TYq`(Qv#;2fSs&kN z@3wc@1w6s5v5R<~xyQa{AFvPFhwLi*hF#6`&5d?N)H`Ys4T$2=;Zf_TG&&$EiCRU6 zMf-D)trwYSzvxhgZEw^(>K|1{qocM_MRZbB866l6jwVM{Q4pOPWkqA7L^LQmJvu&; zXi^kKCr0^E&!{jO8d>h-!?|xai^`*R(XePtbX;^)bWqeL8WgEiTm1p7d3Lb-M^xSZkPLKG$9)0zKzC5$GC5zGozE; z578OXME8Bv!2Rg9N8_TAZbvjFs&PA`PEj%Ig@dE6(IHWns84i6)Gz80WkuChii(|q7J0%yqO7L4vrG60s;eppZ$))I3oecpd&29`PM+`qw6iCigR-xngbPtt zove5y_!!kTu!at=M@u|T>p#rn@Kto}22OoE+!Nk}mU>u839BcMLCZkfbO2PO(Z1=Q zM%z6Av@h{P2BlFOgVSiaA!&|5ho;e;h+%1tM~{Tza3biFfl>n_(x`7o!$`Oc)RuA; zj7p>ZrgoLz!EvCrABIY(0$mZ0PV+2!f+u_&9pee_LDdfkHUCK-r{gfz6ROQ>4=YHK zacN#f$HN5p2(%5#r!X>VGp6*F~UwkrQFm2H(a0cOa z=$Rg)e%5-7j>B2N+S_Q~pW`vwueuTkqkVI($7s9HgY${kxM^?!;fv4% zo$j&Mpci|>$IweWt`oY@6ZS;4e<9Q{y4PdhLhtj~zo3geb{%@Z$G(d`;IXsO2R-(3 zbg?H|jy~kESD-phVE&9g;t5|vmv~$;s=k7qiRwIn=tWe=9_&@&(JJ&APZ&paE`oU*ea;iEL05Xhdg$|>Q0K`Do-hkl zUxDWdkyUB-Ltjc0pf9IsfWDGue{^-4X6WzJv_fA^lY?r1DKSu=l&+4@ZJOceAJYs(-vk}=A)sTd6o6h&mGPisrqqJ=i!u?`ranr|wt38c=$9VT5Y>GDU|uyrx2I8i z|MVE0|NrusKIqpTqhqIb!E{Bx@faPuZ#}7fD)kRs0^R8`{n76|M#ty}55Mpb`4M(u za~!(cV=hMbc#O`6y&j%Vb(sdjCk>L;9iG21WZX=Fzo--d9p+e{1_eO3}PD+OOJ9 z&~*e)^B(Q{0*|g6c(V6sA9IaJ-J{A-ZJSaL)IZSsI?w(dZEFXQ-v8Md@MzyKUsH2- z5ZWn?j!9>at`T(ZrqKS={sLWV@LccFv1LxD=5>FxTN>?G9S_ho1<#8f?Z2KLT~qM< zC_493eb*ZWU0>+=lOm4x_2^oI{RNNq(_tQ6i}1YZ(Z1Av1YM7q(lpwaM|gB?!d{3+ z`&IiFbUnhJgh%^Q+XK2DVSmD-{Ws7f)u{HJqJ2C#jrRQzkFH&Gt&l?dSo;oA>wqH( zD_Z|>kFI6doAGFW>zIMA9oVb!sP8()pz8wmZanIT_BF^A=&@UdYVVqIP5=o&O#@9WF9&tjrw|qN7p{= zQFzWlYdyMdVlT|2ac6sU-NfFQN8_|^kVnyT)9Bcp=h3we`!pUMJ52*!_po=9Lf0(p z=XkC{FY@S`#Y|75{duuR*EQ_Nd33xkO{3$i{Rr&NiP63W_UXj@&J(_g-jOB`UFb=z zaql6ll)&R2xf@NU(LVm8haEpLY72BdV&3xTvx`}mrZM^_kFM8s9hu?~^lgvajIK{p zjlSa%_3V1#gj&4Y!^XpTOKHHhk(gf%i9$lxK zzo)4{cX;&q(0r4ICYxOzb~DB7_UL-Y?D6Qj-t0{?falB{k6epVM$z*%IKU$}qPZSD z--50lUGoOrJce^TIMk!}sG!QDYk}YdkFF7dXFL&O5g4cL|X1|p6E&RJx}yJ`o1T63Ekj{UO+$aMC$+79@`oH&SMW|%}qas2K$^V zA!DFK7osGDmN0W%*Xv3&9j)hy&PB645$9gWTvVbNXai4lG1|}*O+y=bqKnX`p6D`k zKTmWyn(K+^=P==kW}=1A9{X3KoJ(OZ!q=c$xB5aqhw7_x7-$)YxPF9M2BLG&t2}|0 zxjIb=%DElRreCxl=6He&(Cgs_;c(6ydmAv;Ynw{1w{4W0=3T zqsM%U7JKyCU^{vA+Msh?=Xjv)?gDxa=b+u82jRA8Pv}E9AMFd}gqdfy0tOT28eoTb z0!<(42`FQSd4k^Pk)EIw9S%oPwm*8bC&)ubdV+!IF`l3eI?59qjvng?`k}``C3cQL zt6((Y67&R5pgxZA1V!jcp5PF4tS9J&&h_YZ+A?pI;6U_u9=&GUn>;}bz17EjO^{lpU-jehD0nxLO~0v)TZ zo}elExhK$Z+u;eCp*rUvU<@04;R!a1#AbMe{)k=X(e*{_a*yuc#IEq@+9Wp9BlKnL zN{_h!z01Qlqa?P_BhAr!Ji1>HV_XzzfiflRy$fJ7}v4=fEKg1sK=pIH)+W^uCebl3SA2DqM2=gbV?FZd!i7^)xVV=a8 z4~p)!#Flx4c@lfVqx&wgVpY3BWcyy0D_M%7kAY!XLy3ZYZ$)o!av6nr%=N)^+ zqk9su)gI}L{@$be6R}r4x^EtP&7*r8u{9puJC138fj(Quv~8e!%rWgR&}Z?O_7Uj5 zX-xYC^jR+UrbqWpV{du%c`l}74{|E{Cyzcm#&qmKPDkJN=(A%?#~5_4HujE3pCe;` z@#x-d?5`euri|&F1KszHagHkbEE;>yqkFfp_dWU?8RPs_S6)07~o=k3Pr5KK005=w}{%j)`%;E3y#% z+@sGiu`fKj*Bkr0N1tP2+dR7O8~f6u&or^GJh}%Q(|H5>d=t|#2HiW3>AV4bHj3$7 z0o`wneeE%qp*uXfFB|*DV`iY=dUQ`V_ML~%bS0*98gySa_Pxi<6p0&8$QZ>NdUX91 zZ|2dpOS}bSvsQQzZRH7XM{}Sx@yzS^0iKX^BA)9Bnb+|)a3FbJLSvqA724JlGT-BI zkG&Sn^VsXqd{1}_THpy8mv}pmuD9Y{Jgx%m3f;K0T#Yif6kRLDdqW?>ucIZv{0i?t znFmUEH+r}yWE|sa3qptXhXI7=qqUw;eV+;!5T8IV^f>axr+X~p7iT=;*N~?r`Uot+ zH_oN_V;=igbh*c_N1ylDchDC-mh&|)@C200YwQW0Kp7Xs&Oxa|(PxW1#z?U@po~u* zV-q}wG7d^WJ$dx4Vy{FCJi+hL&K`R`TIzA9qO(1gYfT>Gq*(ehk1-wbM<3+< z&f_jYZ}K?$BX1tuO#YcDb=p4W#}g!r(^LMa1C*{qhEL|zAy|aTrFxm_G2{gIOcAC=+X5=zV)~%sPnik zXykD{(Rv>HEt=)AU!wIrt`*wAW4}ThdfX(mk;gHg@|$?a`OH(r^+sbJcQBgp==v+az@uxf{6df8oX#(T zj+8wF?e1}Bpglag9?aK%0XG!YHh>$9s^4H4lYFfkbRCzkcEMGl{XBLjTJEuoeSUwB z-GmPGIPIrF9*0l)S_imnRO<$JCVHgDevOXu*p2A%9;Z6ir{lB8<8<8a_c-nE zJ#IhrWq1WY8>6c|`rMQMs>kU(TI11Yr~KDFb`Sao zj~j`;>Cxw>{B<7tcXX4-9f*GAvHw8RxRCI6kJEYdPmkRu{H)Gnm!RZREaQ`~9!GyC zn7fL75oPR^@M*NZCwvxd;0fuQL_<$V-z0K8mi|k$@mR(-fv<{X%o8zaOPINkDDb#D z(L#?tzb4vw^cgqN-eZ$!M~^-SCwh8p7xYk%WgHW|Jo*fu=Dg%ej*%_2?c);s}o&kCu6KA0koi(S3qMg-7=i68$~8hmjcI zv6Ik&9(y7>$YVLD5`#UukCGVT(fyLdP>)rAhk110BB6Fc_cjv4J-W}5ILc!=rxGJP zb^@xtf@MA@wEbYUKaTO(8g!IL_f8UO7wp-n_9IwrllCvzN;HiKK6TEhV&VU?-#62GG5w zgxUh#lS-(6V0GM1@#sENLdOH_8R)4VtK*|%54wky(0K+{=Y`e*_FQy|$Es?1uxjH> zpiR10lu%#69)W5%?&8J~if$F4%# zdh8o$++$axc^=)@EJ%3ljVPV3*cJP}4k;1mej#I_M6{`pF;SucDC43;*e%k6lIK*h;|e*MoLtIGDb>7e-tuCN^}^?7%35bQ^*)8Q7@D+QX;j-7%7qZ z$QUUR<50*LDbb-QW28jtm!^SZ{ugR{AkuWkNQwHRjFA#mqv{hxqfzw(BKozEF;b!m zRND#BNvO6BqDqu8QXQl&A_-yAWv~Yo9`ND#~~%k@i`>CmM?~ zc1omL;E4vIg`Vhiw4En99xd`P98%cc6HP)7@A4V+qk1RObOi$DzGF z(NSn0Po!xs1QOFYp)^e|7P{n5`89f=<9iB3aHJ<+k~5uPYS%RJF3sQL<#_EUu? zIvUkE1d)!(08cao)w&_7kLoxL@)J3F?5EJNp!ZyEl02QxF^s# z9=8m=&f}J%*Lz$Ny}{!iN9TInQ|OIw6Lr0e&hxm((3|0I;-5wr!aaoFMjwVp2)~1B zec(2r>Ib-usQL$PE2@5j+lFd=;66arF1U};6(09F`i#eYi9YLbThQk`?o)K7$9;%C z?{S;Z7d-9@^hJ;R3SH%KAE7UK+-6kU4DRpfD<1a`bhXEQg8ts)K0{xHHROK_ecj{M zqJQwXKcjDWoaWUua2off$Ndr2^5E3=dY~^Ib_=y_;54t+hhOfmsQLu%HS}ALqt13y zJmH_vGd&?bw7c44&qEozc8r}ppPy5-@L1+=QA>|y{ub@;vCQA1o*v8mEjq&!GL}Wy zS3<_Gi1SPd8SkRAJRxITG|%JiLKgt)m_tRhQwbT{BKk!M8N;Ft9{U4IyNhUV*bn^% zzN75D{N$sR#|=k^crF4iSfC4c}b~CN_nDUnHVXrF6)#u#mRW}$(@oxaUw5~*C`nm z$7`Mlvs+48d9p=$yt=wrBwzM!TPX^_qrp1$u5(LV})g}z7N!MhiKL9EoLIH2mC>A?lD0S*c1*S`uhjaIt;);O8FJ$_@#Lxz zN!xDpvi7ENdH)IhlTrW5ykuBdHS&Z?GUv{&j3-BoAgZ(~H=gXJpleq>+a@&2%6PKiZ)tUvW~r`7dE20PS^Xwb-oLCv-oA;` zu=wW_sgbv0I^se(O|OpkpPd+|gPQ7W$<+Z##&dBp<0V6z7+2w|X#5++lLeHb@V=(i zE!0Hkf1>P(#{91i`ZGkiiM*-~OyZ`+OM{?)vSwUGr)0BYnh=jCo0bpJmJwitlFjsJ zBuC9sy+HDc<89grM9M`svy~TVq#jSWQ*dV zM^_F##z*Gn5#J&epIy9E_A5WOa_N5il_$-(vSjm)dde}i%9b|Of12@6(zIeM!om@i zOZ7aYyUJ!W!c^O=LtcVnnK1J~)3pd^vZhwil7aX?kl3FO)^7|Um!uX6I=MV4{gxSH zQfFbyVp%Fd|5266{SsyI{>dgxzs3osTv@#O>6{!R&83Bum6d4^WRqsbEzQpAn7p=Q zZa&ky6>V(Uu~TyY;-yA4hyGS=UA#0@J)n50Rn0A4>QvhlFO5_WEM8hqHCDVdOSNtB z()y|$i|GyD|H-JDo=(I&C(Vg^wscAs*GX)(FYzp&*r`rpyM2jg`^0#$B%5~p&34kL zCw)`3opsxnNBiRVl~4Or6SPmYfcB{t(mvI8v`@8&_Nlg~eX0l1KGhDiPqlM#yd-rZ z9b6o*PUcj{x#*c{ofG5GWM`eTU5b+jcT674`FIGY!a!#1ubz;JalI0HVgHXgn2w#2 zT{AI^FYIdAb+Jd!#(?fi-WZ|G!@*xcdC6w;C_4 zSP$@5(J#?!X?N37+uD=%V1XVoxA@Z-z)(6~;Tq z2kIq(t{psk_Q1qIEO^|9xbk;O9-gT!)%)q;s@4V0FPoeh;!>_3owXMSB=;|`9FfaCHC|HHd1)7u&3Svo z&r(O`j`&$>+0RllW$W7eWr_0QWS@?8Gh_;7Dy%3@_U$;E>8EpSHfQ>8`Nn{DPIke? z{;4MGG;Zg6ejE$5GT(Ha&I!)6&YXF^K?91HHsD^Pvp+TS|7TNr;IB=v|1Yyx`&+ML zC5c|Sd3EMXURB!9fn0$5bjMKwp_50_%|{ipnjb)51OcOgql=fB zRLn>MshDFl&p?t!X&wz6t9dkVoaWKM@x?qzNI9WOLfqR4RTV#BeB5Y4KJEm~XEb$; z=1YZ6)O@MXNt!Pe8mo2oCtJ1Fsey4?rv}Drof?>+c?J=v(L5TMsChJSvgXmiDJj1y z2u(`)rJ++(erf2mlwTS;J>{2%Ca3(;(3F&48agB8mxj)yk4yFqU~TFsSxUlLK6nJd zv$fMTTe6I!bGX+M)I3vtQ1hId%A*OI=R67@w$JMMsiRbhX+EeWF7QFkej#=a^CmCy zLCrAT2Q|aR=NhD=M)DS! ze!M}VtxH8UDtn>Ds^+U2ud2T)DitRU$=1B=1u5P4pT<2SA@}s91!mUKJT1(s)Z?0p zrS0{2MV9yosi?|bTBK3WX3Y>|OJ_|OmC8wi{*^Ww!JBM)@!pIBoNLlyh1q*$(q6}n zyy6L06G;V6!vCYc(K1>_@Xp)!csg#!llQP-3vWs7vUlg+VWj+&_FkRTEkWo1lpfYk zmmeqtwF6Awy%(9%z2|Y1Iu84pOTuz_6J7j%bVfQ?`}%!4KDS+JKF&V|M7%e045*vOtmEwHEI7P2?No|x`cL|b`Nck zNXrtSTo!fIr;hs6QJ*^MQ%8O3s6P|%r9QsYUkh7c7vBlbhGM9IF(M7)u$`|>W3bU; zAWvg_YrF(j!v@&Fdn_A40pKt1xRj>Nphl#bg)Kn2=GbnI?dBDL?dDTq4lII|uwG=p zSwNdwP_9KAm=5z`9c&fBgkE@UBR!O4tB9ME1wG{R@CH z`;UUDK)w4@@BS-cJ!})nq0XE(uo@_vL)q4pZB5zMlxG`Fw2v+j>>NKDoiS2y8mj!Xa zZUOZbOoDl^3bwJYmjhj35LCk!z+OA*YgY^vFb1Z=9QMdKF59>=gYd3@TK-%GpVJ&PC zDeVG-fcBM6gSkL@DeWtzY$;`rpzINpEgJ!oSRjzM0)Hy-r(!D1f$iM=vmgg*VHT?t z@(du)0DK-$32RuD5I=}KgSx;dm<+UG&;pUc*dM$Owx(YlL%t#88!`wc!3>xWNmv8; zH)JQTmT3mtM21n{FzOpdeZz)94NQl5`~+YcubC+U>Ksm;M@{7wGo`#*CL6Z!Gj#He zB;Uv-u$ouQkbcY@Sj11l--YeGUM35c``61HSBp~j@zht@2nt{(ECBLUu7xeIOQfnj zi~{_x!hY3i*Z@0tCCyw|46A^8MpMrT)N?`{dcii4F&5guRFM;@>%;;m0c@T)8Mca? zGzh9;8esDzY@US8ldyRbHpgOfEa_uOA4~dJ(#Nh4sm69SwyV3tFsOx%{EQsCHLn`M-&*{w#ox1L0Cvx=7CC1O5PuH7 zPn`^uo4OL#^Ye>RSisLM+CVW>z#5VB=ff_32A>TDPy(xgGSjd%4O2aEVwJ^8Mj1zSX}nhE5)YB{V0;;xLeomha zglDf2xfWlpT@2JWhd!7yOXRvNpzL+jdEF9z29JN&zYE(%ZWsnNuuWv{7$AM_N|74{ znn4^$zi}$e;dM?GFbc?blZ8qk|Ge(7P~_$Un9u857Kq$R_*UX?rLOtYVIEL!{%Voi zMu^~Mc7%C zBybsI*@;$Hu*70hjY9Ri>UA!7;jmSgz_z>ZT7xNmVX+Zr; zEKp_%HXqFb{CEs~EC)8ic9F+ti6rB&h*uz$0Ctwr)@5U0GHl^hNb?~HYk+c3U~hRd zXb+`O$*Yk@z$BQ#&vY6=8zApf)bTX&D>zQEn4jOw<5fr%fbHk#pXXMJtenH^kFsGJ zuRh9x1wh#sse4rptQUD{Dz7;j#_Nr+wVJ%E@o)7uUTaj%>x_EAa$aMEzJ{*Z#ZTVi zu$G_Dk!S5HUQdLNZ_ebkM8vOyKNSP1-xDe|K4rJ>x3EsKEKx< z@acW>y^miTW&n0Sn8PcC$ot`Zk&oH{b#KJCjf;3~&>&dND}yM%neb-vZ=u{LrMwo% z!WLc$G!H2A*?QQ)Yk{(%JB)&n_?ISlVJ|87RrY8yi#Z`kay=iST6E?4a^kz zq0xV#nDi2$+z&fMekA-O<$m1BYlp_b5?(u$&F7>uV52^f0iSKkC`gJi-C?JgfX_z5 znPMzqmjfGMhnT1mri-aJ0w%#M;4{;DyivN|Rxw#TLS+>I`Lb%oG{8nf@->_T*l)N7 zw($xf3*>9GkXHx|gBrjt&oQR)axqOdifP&i$kTKXuMsNdCyT3jWzYhC(nvimyZF}t z<&dZKOfk8XZ?lqD(o7W-BOIFr9JeiiIY2w&%>esx?B`h+1?0)wAtoQY3H(j05L1u^ zGsP5U1L^JB!+c)ZGnrTQ41!63jf1Lzbk^dAHMr?8mDlvNfmN_hOfmHpSHN^wFQ(Hd zAWx@xuvScGeCS*P!vG&UQ|91oSS_Xt`MO}|kVb&bLyBQCBw-t`?!l+79Czio8}_@A zw>$Q_uMyJ&8$HIrTG%3{XDuv*9byh8?a&$^zSl5V4!gwkE`=FDJA30tAJY2Fgn456 zHi9KQHd9wW@~{>*Wussfn_sYM`Dz-x1;dt?`&?qjlH5s>$oZDK~1z(z60T9_&3xHdrC zaf`(qPu}CF1Nkb)zzo1f)hwW%s#UxurvkQ!If3IbgijpC>u{!t8B5-3%2&TDW?Xxq z-1reV&h^@snVjfx~=HZ!Q9$6x0Nh2|j#>G6=OU&b?Vv<=f z7m~11%+h9nuS-{nSyl_!euDDLIeu~$>=5%5$4}1@vqD)T<{1lvfI6Px_}Lj^o@)c| zidjjS=a-9lfjVBCEM`@yn3wS3C475%x|mn6yPELt=Zkr@i}9) zl>eN(pX1jTZ?pVNT2e_6n%f8oQwRs(jw z?g9(M?5Gs;4e8&^74vN)px$q{iutY-mW$a*`pz9Dr%@TU`YIt_z)5_HZ1+H>W(GE~9|(Az47a zL-3{R2v{vaH;%illc0ME%!IWP^uTt{8VPz00>Zr~LsEi1T_or`NrI9#uw8<~7D>>r zQi8)vB`CG9UV))8JhRDzICzMuPs79WYmdfxUqELA4SL#>Qam z58frgkTHP0p~Md(epm_Ykl@H+K;6SR9!|NV7DzCn63BZrX-7|mZ4!*c&M|F(`i{Ye zQQcvs1jo*R6+oWj;xGzk0cpo!_xJ+9-tlu`6%bcRzDn{{lCP3{mBdw%uZnzCbAWtR zJ0uuQ9iz!JdW{4pWI+kcgY^=egpKj!o7f1(05(pZDZwekbMFc!jga6}j!z@)^hvNq zg308aTmjTEB^wA&nGb6vIHL{J!fFZ5B<)PfoJstdyCkS3t+o_uAPE~KIE#AD8U<5f z9#HNqd^nri`?)^a9GCvsHqrl$*Lrf^&0#`p**}|9Q(LIG;S{V`thl zpzH+&K$v}m;KD4ZhDCtQi^jlW*e1dB3YY~OCAhd0mPl}k1@c@%_)_Y)v^&fIY|X&d z4D!vu_6&|MBYYY4T~7SvlVPI-S7bvqEP|~P%q#|uXRegs%0__gD=B+b8<-03N^o@- zm|L z@z*br;D$J0>xTJ29dp~mWLPM{jRGTp@b9vq8nAN{b>2iBH?5Ii9{K0Zg{>0YTmpn| z-Y&r{gm0M#wBgn+K>l0TOE5nU(_p0pw`D^mtdijN9H<5C+(A3^w;Q56%MOSStjJ7Xh{&Du9I&JlqB-%N{|ng!-3I=27xLHXY_j z@VJE$FcVftkfh!u$4Tm0ijAckC0K@?Wm_b8q7;ZyWmhhMB&>#a zVO#3e4#D%(|9pEW0epX+`q^6uo}UA2_*!cm#=u4iUYskzD$-YBYt?p2E5~vRTn7 z_4yi$j7_yitaW1>)C-*Jt>4~nv0w9MlxoT;rGE|UA4se6S|(c3pyu6L=Jn9it9c@J zqq+8}JMVmI&yMTb_q?=M@ZQKRBlpV4H{LL<%-Q&xsy{V`IX|6KTm??Vul?|=nY5OC z>0R10%h2&$&BWQjarp>;a?6a3a5Ht9=f~R|(0czC%_-M3uU>t7v#<6eK?@0Pg z#_8`V50~LaTIaONZrZqEgRDq$O>VvVtvWh}zIpea>S6+?@}ri`nG!hGEju#ZYo<=E z89#OO5q^#OSFFYR8))<7>|v*RQ<1-=3%R-*IQrZS-TxpHx5Qru*@fUyZ*z zpl4J5_elAFu-shQpuVZ++8a|ZGvj+oXd@d&GbD00a+k1|C-uzPv?dtG#HU^E|6%UU z1LL~NyWxB8?4!-2ku=(+(MU7eWi6x8IvOpOtkq-5vgNTXS(0tVj+1y_vf-M<1mX}9 zAPE7Ap>0aj_9H1yC|f8^8}dR|nwP~1Yank(OMpTtSu}os&vWjb&2kd@{_{DJ>^XCn zbDnd~v;3arm@G?*#nLy4hglbG!fNfcVk5Mzd!up|{1;H5b!D9=Sg=7HcuJGfn4Mi! zk?qTI+ij(FF26QE0q?a*oV7w znT~7hZj-(y^DxzBv&TonbkKsF|MJLcXXon3%F4j__`r&KxqW#s`n|!G_VCz%@br&? zM$`{_AJ4NJ4Hh%=bTrwueq<>nN+8sR4Pg=BTFNS>N?pXHb@4e7Po)Fd1?}%}5vLA<|Z%$)Q_}M%s zFris5IK*?6+I~^f7gF0lagFwe6WWikT$a!OnBES#XpOzsXZ}lS$NLlS^CsN)QT;y9 zVq-%4A11V06WTwX)K0QPzW>8X?X)Lk`vZDA^^4`TZ0CNhL#GEd*(B*f@>*X1Y5h4g zX9kkjvi(n~9lUEVlw?GSi0h5^WrO304}Whyjo5hf$a zHk=Tw8qX>goKR)~^EE0ai;-oWE>4hEzGT(KNo|d<%3W4k45{MWkf}6?2AdQqtWNmB zH{04tw8UhKrfsm=(b67h%~XYd^9{kNU|WA+DKxORGcwlQF+9?_WBcID%wYdE5fQ!D z+x-i{k<~Oq#;%@@*^T~%zOK#v{`R5pcu&vx=C0tPAH#{wZG4S9!gHl6kt@H29q<#@ ztp-COs7C_l_MHU<%qD@%Zy;_BS%lZ(g3hx^Z+>mNohpxf|TvMDtQOC!b?aQ+#NR0F$&5o4oeV73(|EQ3Yy|3x1a zJ<(?{&;L0c{SvOjGwI}aGsaw|1VgY>aJHq2*)&5tFel9>DF@nsX}E<2G4-HKlv&?O zJ@A83Lz@vBQ&fZxy_%>a3Uj*+ApQewEMvj0$NLdoS?`*-q!%6P+Mg#T(3%NpeD58H90F#R}+{G@DdSPNnc- z$S3^g!GF6WuS6oRsF$P9ijFm}2uI-_m2z+j&vYs-(#*&JOhXX}J4r%2Oia6V)rd16 zJ0{a@s>RZ2!PL5-AL4o^O)vk+u$4(!2qL%A4>Vf(&(Z4l?4Fsyd+FCJuc#U0=kRBI z?bq~n@u}-ERvr`6xSwgvw87X8V64C3v0|OU_7$2?hKN?KDS04`J$~ra(5DZ4_`~A- zhd(^6wogy5eSI3wR+R#Zo#kg1D4YYBevV2{X>u zo61NhizFv|LsoimMsbY7>5z7Tgm=0syuQ{v)<;2eXe$Mai=uFGv3$=-L&UHt&@&PV zto+xM7@eAmes1c@>W-G4iI8et`yG@P1>B4J_b}{S159tHQmGsW?Z@m2<<2ZarUB>5 z-l?S7(rsz!XRX4xLD-Mm0r~M?`c|TEquGG;Qi?!=^a_aMS zJ6X1q+?CJK?PS?ba#yxXI~i;Eil5hTk+Ll1uIH+Syq?4@2kRqAdyR{)C2JF=E>+fQ zo8eP&IjirBWH}p~qc{zv-7Ay^$R*m3SxQ}~8nF!!O6pl??^t%Ksw~mAWScEL%?e9M z$r4#6jUl&63ypV#$UJa5G7<`nM9$bx?jIZ-9i-*I|0I?J-i>#tr+9tLXuRN6{0-X? zo0s-&y3!D;wFux}@K9ZY`=u-Cj%;uxsfmL3Ia>`*kHO=Zj~x2c@)KWKee?iW;4`8t zdNF!lY>j>f@0|x9#`7_Mv;5xAX#DmZ-b;9yNht$lL2MSvkGcmV$Kkdl<8aca!(p`L zgZq)Hb1ovr0EC?;9rpy?%VXKW&wNPyl*gz&6YrAkXT)o=9et3Pjcor6s7o|Sx?Wfe z7jP!AQ?Vf};;)7CF@GJ=ha6?p&kREK>^7?fYLS5212V1C=?oB?4k3Nc1oi~n!$3!Ih;<50t=ug2okywN01K|TtY7Et7r5j+g;n6@Pp`DzC=qKRN zh{SnG6i9|VOo0^qtxf_pV6wLd3?2hiE63(LcQ4(!!(cX^oH}APV=n$q^gtZ#Y<}0d zw&;7r;jzb7qJN5hQE{}?sNmz84l9s*I|JA>l}*1k>@b6Io9fIRQ(u{#{etW z5jp(>)nwRl`ui#_zr1#D!RXdX@$i~gJ(54VwJN%w>C5lnd0N0b<1#+AUFSWKD;o%> z#r(^ZGQaHi2j9eycF5r!-hFAhw@qD^W5PI}FBqM!6q9TIx$7}QqrZAG(Eg=_CzkKxQGW~S-^>ufJ zLiVEvMf<^{M-N2**TJJ(2ZLfvhg`6RxerX^y-74qZP)pJYP(MR)OMZkQ@g}r<@0qM zb`ExxwEd}_>^;(>$lk->TEB|fev|j< zzt%iw^E@Bb=2@q6or?ZrT(Vuq!D+mS*FUHZ#pWg3Ky;ATe_F$-<@H2o+5SjE`-X(| z^^t`6RoEVu^wja|IoQLrCrXq$Wv`UXZi0OzRtEti2@0@|&?$l=dlgv1rHTRnHYh9W zT}!yNtE^Ng-pbOtvby5J%yf%MDG?>QUD6gH+*$)Kv7sy$-5@q3Q~#F0U`K}BUUj|_ zcOgLSk88W^8~*CXc8O`;v>bh+J5rcm%+R*TPTX<|I_bIc9h}AfD%MOfR?Uf!6RSq4 zaOb36_m&IFDtdED?7NPLfi|#OPBoY&STuxHI7*%x&^DJ3~38 zB|ykLrJkY!yfN3C?X`j-y8_yPT~IA~p?NBN7N^5WY{W^UHspCGTt}DZJKWob9Y>dE z12x@0EF0+x-Q3-GxxD&(Lob1k&E2=v_@hrOAFdl!yP{8vdzuH={u|@1iQZn#>xIY4 z^M8xB?v`WgNHN6srH^Z%lpe|s8yY6UQ|)rq1IeXtb>NT-RNn&L*-0?xz+ zzE4;+JQ@J2)2u4t3l_V|8xB=fh@2hzrHaj_U&&lo-UMt#zhyEFFtGlC7FcW2t4?Q0JZ4!gDF5CN@2rzbKfKlv{!{ShK#zx9H64O z$64jEV6Y}%g*k;IxRU2WJhE!EoA#YLwQt|4F8_%i6AAEh>CeT9Wf59Fabh|8H-mc% zzWVxDuLuQ4M}yJ7=>_VovMtY>+mu4-V|BVLq3O7lGJ#@b<`5C^*(sf77NdZ414@ z#rDXM-#<|2J36}cW_Xi&H*M~!?@uqwSsm`0Yi{1r+0xh7Qr($Wo^#X0o};8waG!bX z=aa^s+RkI=G5UFoy_#Ri(4OR1GMQCCJ7)vfy4 zp0IAPp5z?p>xuYMG4@3{?bv!~7#Y4c z!}cKu8~VmE0notYWnfgm^;c4_HbcS6!fOkZf*f}>s3E6A_FMC6#%3eHUAu*{yM`i}Y><)ox(Hzsj{NmuBsNa7m z(%swB6S>}gWJ%3ldzDrL{k?n`@8#z+E$?Sq>aj7Y?Yd2r+OG4?)OLLj$ad0w~r40ov0kUMquvsaj*eVx{aFU3^cxS@YYKD_Qs798Y+(JIgLAymmps+Z4dY>7vpL$he3;u_-pGcYWI_g`jUAGfcuTK~w zuBS1|=jb@3yq@M$qhExFyA+l`Grj7A}@90BlvZ zG+`K#5)m_u3b1vdcPT3=myu266iQhM`PBM)_z=`=1;WM;4^YUE4u>dH(`lb2XG8{&uGvoB`F9X8LYMXPpcD9LMu6c={6CO8& z=awqokf#uW8@LrLbqy!e{WPUYsUtsM;$#UAkH5uZkim9_>vzm_Lc3Hg#?4CuMyslt zjNMyy9vMJ*iE1>B0Q32Ydn7k^#H|)5{L8C)EBdpu`zzKiV-0YhnBV+)(%MUH*ZGHR zCz{FYb^eihJ+DcO8I~Ahv@RO6GXq9+oSX@0ol9|L=V!AZa~fKqb`eDb1kq}dg_i1X ze(ky=HmBL-v>jXd+Ba8kvpG%p_wC}Sxb8a+Us{^a@tx=&M(=vr;Y&~VI(~+Cd+=^Q z=$)x}Lgje1A^z(4OhUcQ&z7)f0<{f}D=+MMe|n~hEQI&(enB|I|B60W;IUdg1qh)G zM1Mgtf}hW{dpwDDsqH%LWINGLUa!;613U`-jJ50gJoP#HK9}vZ&*gLUeJ+fN*DHv1o*LS z46HOTysY;xU&(*FZDUv-o!JOpr5Y_0`!|^_LYNFKTgH#g?BuP|o15EHAwCd2T+x%8 z+e@3qD}IAS7v>?{7ar47Nn=WF*XLTc6V2uI`dp`8FZYSG&B&Kw!fe1c#;zuuo|GMm zzJ*7Z;-7#KC}x`(NItn3vd!?&xeesVa0A>HUwQ4d)nC3IZHS0l#X|H`(K|3IFU<$p zQPPj`H1}QlF$e>mV7x0|@e+QR4YmP;6;`p4ka5IK=l~*_QhP`Y0`DpW8saW1@D_Sq zx#@N#U*zkYn<6C=a4Z>=nPR%$Jv2QwLkRK0jJxQ@f7I}+OIrp?vu=#&Nb#P-_M&3X z^c%_8m8uLvF1@L41Y+$~?sy_!UrPn1v-4579qqgGveT)2e|nfC8?0 zDWq4YcD)uHvNKdvQY;iiLwZZRt~{qBD-%%^9Cm2e02J2y{cTY4EglXN$%WnwM;i^? zPpW@T-kY;~8>jt`o@fiH5z|fcd#~TATDLb3OkJ!G^x79ETPN!3t_M$^-LpM;iL|+I}m=(Z$+tj$MBt)?O#xj6KI^zJuDKt6%HB zdt=7vwjp7;Kw_KUIhdz0GZ{k`QH?Kj8U)su!hV$bV0UTC+ckwZDtn zfyE5MHfqLNu2b5TS0qAcuNDA>g<>VFF^xWmi9CcKj%#3JOTx;vS%ocOktZUCq=cCl zfhYEwr!?%b>8d+T2phsV30l-p|2mLke3!^~rQU%!uyr8EhI*(^8|&K}+I?P%sfz8G zI#H)Xj_d50Rvq&u< z^JP%VU?=!7Uk#YAe&x?X_FSkFb;5$l8h^|Ie79B7_^4t9gl|1+7Xr~2R)l7yL9j|! zUjJlPI)Y`CWm~4sjr_5h!EI)%Macz|w`~t$}_0a80nA0uf3Cev@+|`Fr3do!UhjBnY=mUan9|igGLRDzY*-X(mVH zXl@ywhoglots14Y$T;cfr0}?jVAvue5%|-$24|L+k93AYoq_&-dv8192rk~{58k-= z$%PxIdIm2Jbq@z02vUq3`Ki)aeqB4(z5gA>x?_)E0$m<2J6`212C#_`>wfz5-o+CL zD;nq^T)1NZ|2zSN@`2^U`{(?fgTV)bLtSli`-$${AEx^cHM$?<`ABWQMcsq%ssDcN z|NkV`9pn0cjCD_Vf{sbPRjm89NEAmQ|5b~1-@XpT7L(COig!t8urPs|wuNZwv zw)_zEvzSjX9aUuq>oNMecG%Zj*KAPh>lz?$>blmxhw1%R>zcy4psvK%wO=DktB=Ei zbUh`$uG6PimS=)ntwN1NP>rO#BNXbu+P2@g@X5s+gZ|qtu6Yl%_fGMW?jE{0*aI(4 zAJ+9e?^Dtw|KLfCD6X1S+Q@4NLxMM?#iDiG&T1KQWVC@*uYVbjPhJ~S$;t`~apQ=(bB=5i#>z<3)4wHCX9Oy<1WTg8}ELyz^;j2THk8uS|qSk&Z*IMcCJ3!X*r2h(3TP#&i|~C!gCv%wb0|@2zkH81TC5E9!G| z($j1f!hbZzt9xNwtpGWE2t@L=x)6vY9Wks_%Q1ij*9~3Q>0j)t?@r4!mKXQ`(do_C z)qm*VRK34zbvQIR846BL+B^0S4z2{e*>-=`u8DBRN@!EK`AF#fq2U8v;c$OH|L|OH z!?5^5HhFp1!;OAsPpLKI68i#9GTF38VC04T>&xP(jh>dR?> zh;_%Avq2BHMr`$|Q%n6z?a@yUceV6eBDUk-7Rq36@1Xs}>bcbuZ4060w%!q+d(&1i zQQSW=(vL2JpKu?UU%!F|i}{oaIk`^zty-Kz>h-r=qy6TD_9LuQ%jbU})?No%kj_i( zWIM=snZ*0t3HRL>yN}{!$aav=yFa1b$hx>}|6o!(@k`l${2J{iV(sV`>*(@&9Rp4s z_eo&QaXOKXF0Vfudk)P#>FBckUDS?sF5^KqB5R^ixkDnJ8x=Cf;k1D3#bB}+kl<%k zDL#bdHZKvNps+ai*n}1y(s0!+W_)KxEYH=q5;6+=y1v%ut*j`>cjaa~I9{YmRFS<& z8(iX}a((H`i$s3{NUAGw;zVGu+rD({*wXUR=wp4o2eKlj7T*R|oufbhT`<_&2kf-T ze{AuA#bd`7m;C*k#_B@O3>iD3rkC^&5BFnzX*m|iyG1y1Y`u|=Ea`Q-MlZ%wv4?Vj za1Ms9nWNC3oTESiNkxTurLNKqnFv9q90dd=rQ|5+wl&~&X;_mEMkxvtJ;Bwii0$x> z<-^EP==n`Aauj$Vo3`?>kfBh5abP~BKaFxBvk6Z^v?ve_D1(#(sl`&T`-zSO#3cZ` z+=w^Oy-A=c`S&~(xy(|(eevS-=1+by^rIh%_f8+{AD`|zHXS|1Yi0tm_#0U!-1KBF z(mdhyk}{zjE->PW;s6$ixGJc7{&`k?TUt9^7?F{8oSdEkf zcu`SKmCtR9MFzs*h?~@;GYF7=`sqWe3<=O5jtGaV_r-NmEU*Dx^ ze?v>8*?(x^_`;1H&5LW7XO=|OaPUBIc%b99jsac+z>dTl%oDC)2*isP*KNO51-ged zlY0Fv*J!^vsr@k4n0)@Zq;}#Fvi$_LgR1c1vTq`X<`$%-$P2>GFu5AzP6$QKQF1)a zN<9?Sp(*(kkIMQqVuFQbqHkt7(%0V64;=r+K+pa;B+st|2Zuw^Ptkbjr4;XJRyj89 z%5(JD@b{`$0HcH7>}K(VatVGjAs)M=`^^aFtV5k`udw5G(g!IHMKE59%xoZkoWCJc z8oDP9;2_4u^)qK=P&eXKW}p6yuzh}7oSTl;Q+%;}2Y9vij#KIprhjVtts0J$+J4J5 z+HX#3kB{N_HQIS>)E{8m@iF_fFAP{g}_wFOBrNX%XLCitikj+LG+l=Q0fs`Ap3qIftKO!YL8M(Uej?1Kz*_zz2tsr)cD4yCEoxK{w(-LhEf%(fO5)G3{nGep6s|m`~?1;fy@Ud z@}&`XDSH9u4bd-hYj9KaO;qRGcM0zlPfQbGB(DHXnu)gy`M#S~`MxD=v?j>Ubn=x$ zW+miX;5{4&(Vduac2&-Wbm)0H z-c6Ym7Bk0PpdGs$_?s8}){Az8Fyph#ZKw`a>0@VhuVCzo(n*B^0E8jHgaX$o7K0WN z<$+N&Mr*FPb#%v5BHvs7=^ZVZ=pOd|I{i<`q$xw(1lRCLB|7zEA1ra^pwq2(Wp7th!4Df-iUzPIPy8R;f# z`t53oEgT*^GCC6;S^KQm=V{N%YWGAx$~4i&8tc;;wM({xR-i$w{k(dJ+aX=l6PPEA z;XfdYkQ?&fA~iQeryq|emy$!U>|u>xj21 zyC$$gYNP%omjc0~Dm_k*v#JtF_Y|%KW0%o7*H!X7CfIwX zR8zCJvL#Jen!WzMZRN#9{1kRao_P(ite#9l!0%O)eo)La2u_!es~XP+Q)LA)INBQ zxj(!|eRv}2Uo20r{1%;!8uG9v&<;K1y!e3Ogv<*voFGL6YdrN{-A99bC+IZrdCotNaCgr{KvtA(cUIvt@YvsM@X4h44aQ(`O<9qiW zKe0k3M!JTEtjDjr{lKwf2X4Racz7Ume`tVzf%g1+=fqX zkK5wEQ`v?W^7&WU@Z7$h4gWjD=E4uB-50mvea5oFEvj_Le@Bd;LI8d<7b3G=mFZL_yyWip3IM-Z%jdwmqHsJ3yn~ojJK*F9b8OHlYvx7 z&3~a0#dBoJp+c1T%6-L!q!1(|zwjVr@(Tt+$uEH%7iYiZQu>R(&4&b-t;I)|C)yf! zZ7Vyv9B!}KX!wWnjXi_!>KQctOL^(h=0W%)Dnch)Mjl)~?CpBVktxbuL&50Fn|dF} zbw-~r!g}H{@VstXhu6?Lkn<|>8yd6paaIH8=~H%wwxZ4(bDz?Hgg%}}4R^#G zsJ-pcpTC+gAat3=B=rtCKN~36QP(C*nDmrVrTflsN7DSPCm#~8xoSK)RaG=UruA?& z#v-LwLV)C_R0B`Y$~1e2zo|LTE@{JAyaC?fHSh1(dDazrGz2xCbW215!=JJd_GctX zw=ASwC9J-)i8>}09SW&X)}j6-uei9XDKC(kCbEhu;y{#dBpu<{y|t8>6l2!U#I;R&F=8P9>F-kSLF5Q#g}<}vyf{B z=v>urk*~vQpv>2Sq=tQ0L~bP;)eNW>K0U;Y5={sJXw&j_@IiXPInWB}I<$2}-u%8u zdvkqDp#Pzc;L48r@IK=%wY8?cvCaSf3;X7+GZWsr`sxu|o^`Ts^YoTbiMO`0%bJt( zfk~hv7$5hS#~s$jy}WKa>tdKc!k%b+U3#ug>pRcY!ML?todHsllWM@dvs|5oC+L3h zE~T@kCA+KTTDdxJOG3RI+_%|9?h5aCD|%)m=II2@TSu-*-6E|(+&VPY_19UfAoT3mD);orMn>^MMq0u z%e^{Jhl!%$zi=z|E%^J}f>RwU16><~lbgFb8~uZW{`NsNuXlG_({!Lal3_@@ZgbCk z+w|tHuFc^AW%M}8qPaaX=2-J8AVcU+c27Kp z1Gv@hk-2bv<=QhC5uViq-e6U~3f|xkQ2?0XP9*UF=^=kQ1Pytb$Qj9S2e~0p!-Yaw zjHtZnB-Kd+J#<&Fgg#b=rvLB{BR~3)yXfMU0WmoJ(T`3?zl?~>g!dJ(7nI%yt8EW*Svze|E@4a+<>$d&BLAeL@DB>$P<18gf+n9y0$wnCnqPnE5;|~k zX!;l94^tN&9$!EowhS!L9^`Qw+*k+Dfnmpwv(ME$1QDK znUgf9eh4XTQ{#`NV=WiMAW;m5ftDzM!dTwJd$|N%r;h-YB76tK?P6l5&->F*HR<<|^$WWPpn}=($RJHO8u6rsXOjk~N#$(=cLCQ%+Y$ zG@F#D3Os=RiW{Z68G1N!`gCN6v_j!+XX}%wd+r?lepj1jfNHb?c88c$Z9Uwk+2K4! zib>VlrM)J|8?RPij463a9h?-Q>Oj&;ulqK^!@H$hojdmj*?RZ ziCK6qW`-=^P-S7383?DAsKgkjo^kQc6P3o{CVPz_`lXJi(8BP9FD%?=p9&USOeTIUVG3(6ZlYSyUtf+JMk5Hz0Oxs zuje(1aZ8Nf3v4MhSE+mQ_sUgjdHQn)Z?I$-4H=dj4u0-$51zDSn2cH0lcGqZJ(^Z% zwHBs58vRZ5rAuigfUeVU-ip!=o0H};L#YUrfkK4DLvw&JNt20M0T?kV6v$L1S0Eu( zN&MB;e_T3e%`_M?EoT;^uU`6=YK;CxPW^@r^*LhO8o5hAJAOXX?$#vQrMBy|lkG%1 zdA&}%c3@HHXRQ4W^>^z&U*G5QIkeB^bM$@A?YwVgJMY_AKWVMYc73f!Sf?T%`PI2f zEbiVeS1BUeP$1$yl=#{d5jRgqqUSkRiJ!}Kxig6_sqHZtM*U!XQ(mvfBF$sZAcq_= zG8gb1q4eW(9dy7XpFVsGwZ-Nn}1trPyT6Fn$*Mq~6DUd`QbV(B1)B$@VkS z2cYyrPoQ`E;5`FZRL%4~XiL}sDYY9eh#KKEa65bhIC?2oGfaCXbcwc5Gt~ryE@{9a zKTx+d2jkU)K&h=<{Ku8Opz$GS51JqQh=6JIKYQT=;RBrM zhqtXf@BsAPmb$v;xh4DZHc`zw?{}I4fhL1t8_c`JzPOdmp-r+cHjC9_vqF7T($iFn z?P?v^zNBzrMLfW*xDgKMifO@A{&HO{m_Qxsmxpk$*}dGgXP=dwo7uJ!Q-=rj?k#T{ zr1@i7u}=H}ba#E7*aMxI=-Hq=L^`p};uXRcuSd>0u~oIGNHtJMzZFMQ6kOX9lzK=< z=)dsp+wHw}Xc%o$HBP#bsz%@f#lEuc6KsSG;VBJDeN{z;t|XIX@D@sPoH8lOazRI5 z!VJ!s;ERk`BOlrj8BCo~EjpXmJ>e3d3HAx-7So#zIImzp9R}{=1?LYb{~EHRekKB! zZNNLFBdZqS4T=%<2Vi3&h}T4QHAyj$_!5OdC@>$S1~L8^VQTO!rrd!L6AfR3>Y-8v zaT!1nzcbWy)lGObo@+%(sjKfcP%sJBd9@E-)y~$CKh#+7YpHH2D?uCuRr1VGyrN3O zSvi|VmN0H@agh}!%i2_+&W|*OWn`GduyXQZW&JsE?Z>U*hOMEtt{WE`3%>vPn$|<} zHtVUjEsc#^+V&H+)iE$&dBoe??0uxFrRAxb(TxK;?2FEDXL!LrCQJ<*$9GKCjx}!_ ztr0J@H#WA@PxR$XK|yUv$W+jTi6+Ye*z<@3*J zI+biE`6kgkqOkXb~G-TACzjZJzJ%&EU`r?B%533$>3Ey-32JT~AUeSFjUq8uxFHg1vzE8Wr@Q{kPsXIq7NrA(nu`LU5B9snwkQU)&8o6hARBT zhS=5BTvM}Szwp$1su~-sJoU6LVq+>&P&=kb#!*-(VhSi$tQIC+DNDCti5SW2-DjNg zI1y7};!?ped8y5PTsnAeM~5DMSWy2$`BWi@Y_8#JYp0n#`%z6u9~H+n~2XIffk zp2&*+Dld?p9>^27!t_{-evBj{Tk`$%oq`HgHRz9O&J_2$&;rOlyIMMpJ& zHEw{WhzdPc9L^$7chbVCV!7X#JjG};+ALpV!*zLtGa`M%4C6!P+H3|{CWv;I zM5jBgWQ^b#9)}E(mL>f;U@;|18rt7S0qkx5;0Hth_HX|-Ejq&|`bWcEC&JOM5!N`2 zcY0JGoM1SApfo)91m1y}E~3A&ev$Ty#!vy`%m8?j8AQ>W$bd(@G=euN>d?b1Kz@Z? zweD52JP;ghPIo1cIu7WToh5%rAb=1iGX-@I-}j!&B4es^@5;a=`g(5quHdK;y9T?~ zC@zfqia0@^^n!pspJ6)2+t~{eZwK9JY()2BjFYd&n5ErAas+l>nh9nenbtzUh5^L0 z2mBa*L{ENBIYnC`$^acqu=Nb^j(P_67gVKX!{)PONt^F^=S1^~rP2aa(u}@OGyG1j zJsjvozoq>}c+F?=g82GPZP(YYY^U`ruh-Xa-0qX@SKEEE{c5{UwqI@c$@Z)5KG`0# z`#4w5i#0-VR|f12E$)hgtr*Xd{#YR3h`Z8Tfr!VU9>Q=kF#3eJD7{8H@=Twk9MYTN;|AW ziE1XaQ{K53?Tj8KwCm4j3a7Mp;(DgAY{xod91R#^v2yPd1ymXjbR#IS6gx{850je| zTZ-2mLB(c^%ED7i7%#z90MD;=QOJGO_Z&@4Pa-}c>rWy_kMh*Aar6Xdbs$Sk59Wx| zT#uu{xd4=j}S=je-X(phQ=J0-G#W6*(a76nRIDOxeUW!5gs z5cK$77y^A5*-WOJAsy{Z;TYXK;G2!``MH!{xXMYR5{9#&7Bm@%MJj(FK>Z+sNX+cRKo6 zF*$uNV1dM(AxLL%XSU!_k_B@ikEQBH$PY^+EWzI+Yv{S@YRVd_M%o5$Oq9RyZ$gx5 zQWuK^vX9Dz(tG~jCGZ{LT9UxE;pnXdz^KY+#NUcP291m0%epsKQ>76LEM6!@)=Y)E z4f%oEDyXOeA-pqQSjDVtr6RSM9@slbnKYxggmM<{SwtwPiMOGKgmxRS9NZNzuToV~ zrI%Mh7649lv!}%pA~02?P{vZg5{pAn)J1kfdq;D`Q(9PQXfgFR`-4peduyJrJ`$)9 zf7|5qHI}%F^0K|P8*85q6qkDuF4T}$NMrxJ__Fu}@>nSr6cOzhAFZ$hpff&G3l_g2|L6L zqhAa7MR{uJ&=RO$`dNKY$rf=zp)8O{)oY6kP{necF!^yR8crXxSg1-Xow6m08bye3&Vhw0^x~b4bK=jUkK?&qdJ@m$``HfsHl9bd1PO=No@?kt#>L3^V;NX# zE*iKvOdDDbkYa~1wxUoTz7Z7Enf%4hP&z$PR=<>EhRsAh*fvXDSbUuOfSW1KoyL%% ztef*!$h)_QbN&FYOZTr-W7 zo={I0x;r%U8G>xc*ejewtx!UHv73vih(BR73RTl}IC2~|3r96m-8d9)g3;v84bTAv zI2@+IQHiQACx^q)o4en5#w5xwe7E$>uy}0k*mo~n_^#R;y*LRvg1&n(HaZ{7jJ+HC z%yq5t8T97!#XtjWS9KaR$W_`ytz`T_=Y;G>aS}Q@3PJ0%rtCJ0!DK3;BPnrVE;>oa zWjM0aEI7mmHQ8`p1OC%b%>WZMn9tw3aIkS=<8=$Sy=V6c{NuQ|1$NU{MIZmrIT|~n ze?~v(LmP02Jn$Jlr^_qjHvlaITRR)WNf6Hgbb=}oTc~(#ne<{4>|0JDLFJ`Ih530n zY$wBJF&X)sE(_ko8}0tZVAp0kmMc8e?(g5+g-DQ%vmHIV5bv=%5?t_uK+v!7i()*u z4(J98Xej+Ph0;?b_lI@{1usy_Su*oco>VF}V2JDhbNv449R`#717yMMuiQFXpdNws z_HfnK(fqZ0F~8CWLAfYD#5*nWlv}jVV?L>#mzlEt^v|@<=rixLW$t6nl{due=wp^L zIsO>2bQc7ewl;Bry2RQty2>u(*f-eSWQ-V>DD|+ys434_x(?(KnJwB06tr%66_`Ca z+4VlGrqhwXx2RjUo3?G)BVNF98h1rcp1+Oyhl<>EP6+x74ujA5P3KkNb3cEk`WcG) z6MV*s25rIrxZhm&Hev6BJHvxU3p8oxQ#Tw7;$xAQ$@*3AT05=LgmPod%DW_u@D7|a ziqANA65j{VCpUk855H66!Qd0g{MSTG5-8)mtIK zoZ5o}Y)S!fsst?)nK=AHDnh2@e3VpnGA)xlKP?lGNzyIVrFxLUoFUhuXT18FYI z&sP+r!<6Ua6uH6zbk^l^I9!f&W?%@_rI74)^19GY!&WnUa(xamsU?m{!uv|kLZE%I zC(^sn9$4tv-Z47*KKyhPgxhxaJ>S3RA0PKG_CMdZyDj`N@$zWs`QDL{-seN3G*4Rn zNX*+~a&3U;QccG+I%f_kC8}u~nK9UQB(C@1nj(c(NbKZEbkhW96mu^yTJh89naFn{ z;}J?pGmnJ&l2Lfn<-|16ceq+mW6vK5=2; zV5F}TXZ>XD*(O}kpKWXtw~+T$$L6Tdc(%rFsnaHeALuMMvA?Ybt+#k?P@O$Bc5)j! zdtAN1KX@cP6Lgs2{z9&ygtKbd#)ou6>QEL^0wv;S3)?IuiL@Y^Vo zMf8^(zfAk^%DZ_VrV;P<`D$k1 zy&8NVENecI`v+D6ft7*#@97>L?Y;-h_BmAHrh?m}Jw2n?hdtm0RR22-6I?(t5^p5Y zc!0dM7$+k7_gIaHxF)qH9hwK7_nMxSj^aAF$L@wOmq$I3(nWqfT5eSoWhZtl!b=Av0S@dX0_;V_>tqV>yRW>#wP{_7TRAADMLDLzqNoEAYFM#!?3ObxDTS3j9!h8%a&ryXF=}85$kho?QNF$vl zQsUGzWtS8pU8l5X^W5C#3%3}>@Mf{lQ(0YI*<%WK2G#uNbF;&zhiBD$qpjC1B69L{ zC7nwPDb3??DkY?wVY{IpNX|H*$~m=IjC*WmBrdROhJm$9E&597YZX{FGDbmuo~K{B0!N>TFx*oa25Ni9gPy{R35O_r|4f>RH{{=pB{ zIR^Wt>G#IY!N&TZV%&|+-lSS(nhOCmg>59xV$aYSUvzSZc?sP^9})3PI$ODA3TIDy z9L|aj2*<1oXeR@3Bq;WRRLY2^GVE+BGSJmMNd4&=h#X&*r*J+?{dn*6O|m<5&?t`T zCRxDe>9NcnV!F!n^&W@bgU_t*0^!DW+L?5q_YBh;zf>3YRUXAOpd<&S3t$Kslw1zl z;zr=G&gcPdS2)RMt(yXLI7)z&Nlw%BR9GyI8*db$(;FssjLb%MGG9x?_52 zI{FpSa0+<~|4gq1pZ+b*g{Qn>k8(_+KLF1ZVKxF>w^$5NvJ8eot?DSWc}2l27lmqK z*U-1P&_vOGtPvL_lqkiuu%Y8eI#D@oE&dlyMZ2~zz;2o0YSt=9Pu8lQZ3+we1LJcG zGcyZw<5TnVQ#Fl^HTbdbo&8kwGg#JNd*AkzmF>|xX7`SD`NggNt}cJ{4u2QM)&a&u zGL!ffykAg3fZ8J~2CKw~#X+^eK?6u6O>u+ygVRB?TpL`{4Dwk?Gj?hxZ@~J2Q9;MT zVU^_Mbv!UI(Z1aO!^rWw&zwIW*fbn<#7@!tmQu`e zP8y|QPr&FQLkGsZ=5=Lx#fq&3EXiUir6WsmO9@T0vl85hgd^>62a363e$g8kWwB|e z!~)~6KS*;OiA}whYg)<^9$y0ggk)2pY=w*-MZY$XOrfY23ZhZW1{hsfe(=hzNM;0Q zoUR6JP&Sa!g&*nc;qHXv3cvNj1v;=0qrua6;+-TzvXqUX zhPQiLvTC3)Jhnz@#q7iZtxe%h{b<;ecnywc#r`5(j_OsLxz03Vc;si`>*T>=9}XlZ zZ6}jrybiNL8i77SfbbYyzT8>}k4f}y5X+M z4iERd>s>u`ZjTwmohDpCd>?ctTtV_#w&}V32TASHZl&vgnAFa8Uc$Ls{2c5A@}1Dx z)*0Z?WlCqL9W;Sy8b9N?3@8mbuSuau8hH+iZXhHV1s<%)LJ&O8py7rxrIKVJPA5c> zDEvJCpTX|#;F<2=fA-mrEQ?WUj(%?GsQC2p(BNPw+By`v^G=ky!`Rs_;4%IxJ{I)Z z4w}&U$f(|ey$0@Qx4>nBwLifMK8{sEbucKARJJ=k9THrT3ZpsAjD%v`X3Dn3%XbsL z!b-pR)!lc`+;#ujm+rr7+xzbR#DreTTih<@VhdqLm0MOq|l76s~sE=6CkmPV3P+lJt4_R{|Ee&>4W*VaY==MKfs83H-lxaVQobSTawPaS;m#)iCINe>?tp(o-^!O| zNnkso>S7`vfDJitur)6y!LGjSoNS&6v?kfs@Nn|jWA?S0iwEoWHTXgFIrxX(#B7x* zr=;wyqzg<6bXu?`L`fXLAy`%jF0lfQ)l5~By9suzi(Lk9yU|RTRP1tG4Gf7+Nz!Bx z3_pPrgvA&pM2EB`maT7}vJzKiekD%#!g26son^rBgSsBD+!;1oH?RHJ4F{k6^uy^} zgNv)jR=NfTy6|H^zOr`ayxp-gEAx*VHU^F_Ew3C8j|P|N=rR047eNietTLdfWg)7= zO?w>bI0ZVycRDKz#e{7O93i5yRCgKW0cLN&eVJ~Hg<~GTS7U&lCl;_4JB@7qS2KJ1 zT6zZd@A%k9=zO}5e5}duZ~B<(9Sb$~8I8t?9w_Lc>Pk;_^mKi-kK8XH(|gdrZ1gV) z>n=_Je^RjS_lUcr|EzkY`v&`w?o3VaD`@RWL%pL>qSeJ`veVBO?^O~Ja0`820> zuMZzQ`^lZSNst>RMQL@inn-KP*S+I`A4m06ze%8)N&-Xa0TqXVVW( z|2u|(StEik0v*S{*?&4IeoBVK?vn+`ETpjBUs(}Dl2r&}Ix-FS%QEf&6YB-1+ z#>3G+X#4F`s@UMV@Ird?+~(J!wJ%(_@PfGOFGuE@L0_)v$ZP%;-t+vK;;^+EgdC>; z8bHN_&PDMR*TlMu-xlk}X^5yWqE{<|>Sf8g<=FOp{;}5UW{=&n^FaGp`$17G>_5k! z=xfm*y@5Y?_wbe1u*MsHAB@`_$G9s9<3$9E z^B&c3at8wQ?soObT!lZS3Nv)y1nF@2-W zfI0ZJ$XL5Pd+)ytN53bW!M}NM7PQvqI88yG4?eF?+VB4V<1D**%g3nxQoB6;G z`5h9+_kzDBo{<-0p13CQ9c z;kti1eop#GGJXyQ^ZVoHCx=gN9Rz+Jdr8#$9ok>O&ncE`UHqIG$olws@>g$zpZ^iI z;U57%_eSf*b8D9gKL;*`b?Su@FVESHZs0)V-iA6LC!-@P@wFx}M9(1a<+p6J25iuqlH0;OQ*cwG{9P6h^{)-fXkFV?Vg z{lzipTq5UzovW)mg#dCMAaNEz2NuHN1!54JDNY-GfFB;eNHm%$gSXYFw^9ac~K`0x9 zQ6j=#Pzpio2ol16H(ZXB{URK!^!X}-rrBVd*!%44rrJ%j;=9qG?K-rjzOohoJJUS~ zx)Tl7#n?ghcYv{zG)e4P0>&PkxqGyix^({T=Xxt^YASnG@3YT7J3CxGJS&{hpO4p9 zR--52Z(4m3@V7AfCFpzwAgLQ+^-&(z+u-Zaz}_LgF5%PIp&O(tvx}1t-e}o->eSxF z<2Y@jzoUEjbDtaT?&!ydlP=RBYyHgq`{s0KuQ(2?92 zzL~N)C>lq{35%6tN;iN30e?(sodXsMRtjjYSN@BYOZ1>#^QS+ZP_a3pcG1ZNpXa)X zUl;Y={U`+ZeJYwM@%W9P`&;7i#czqn^U7vCp6BI%g2yAIh!m+Ut7>cQ^HVdwR=xku82w9A^TX|pFHuSre$lx4Kx2|MgrQv-`QhkPa|zVDu{;Y0H;M~_8`R4k%W~^sm@Ob}aAP|Egl&~iV9$}gY*0fSY4-!ZLvrwg$I1F!FqRJbk zTt1{y@$gJjy+;Z-Vx|@IP!JNII{RQ`uU7>(aotB8U-8Hzvi9TYeV1kidqsvmCayZ`2d{6uQ z7C1Z$Way;0JPC&fy%X^_+Gd2qKfQQsy4_%~rJb378aVv;IJArpR#2vXg}7nu^7sL0 z8W`s&#z{KBwQ%@E;2{}@Pf-Agq~gQ664(%qsev?PybAByi^Xndnv#x*4>}GnRYDma zl!(Ji>B31#gu_4e!R;%oVyfP+?3^SeGx`c1M^Dl*2FP^n3=rh+8G+-dU{EUgYz#8I z9ot^399b?XF0HJ%FzE~s;As?cOv0AqV>Bq9Sg}D1ig5#rK6!E(XMl*gP2KVgkQzDx z>9(Lw(WzGZgaqfW-6I*}mYhz`L*a8Dn2RXH&=M3Da8_qem9p{|ux5?+4#A zbN=DAFFkyI<~Cyj+{*SNT`>A+3`VhC9k+Y+JX1iJD@JPEk8HXp040Fd2{M%#eZx5^ov`hs} zIm4+zB(os22w1=BAhbmskEVm``{&xahHxC(V5fg>KdU@(TbX>DB-2u2qVbvVT*MPY z*Zeldp=!1@A|Z|gJPL>o zjRzwG-6dW@@$U#LV_E`o)ab~dAIJ!66=bA}>dhO?sHhGy?z+X{bUJQk68>UjVfRRM zgQQ<-{3-h<`F)5q1-)Q35Pk743pfSkny64*a01jS`61XZf$20FP4M`bOf{w{tCg}b zt!Y+7TNo`y)LY}pwPA9d0Jnwq6Bd6R-7jv%&tD_vYC8H={CsYDn&&Jj<`p#2VqUSX zUgqzKCh+5fChuaJjLXmHA9gj4`X&X778j;*9GbJruzcI23>h_diI z4d|8IX>jL`zx=XzK6>xJ|A#ml79Qb(FYo8k?~>_+en>ox-ix?ce1Y*b}|jyGi^wTDqy1{*AEFNAYTH z-T2@K^C^qtkJXYzPMJao3plFF>&8g}kqvt|{TVdk0;jkHeCF)6u94}_$mGmL30YsY z*56h?pu6~K$AIlfc}WVZK}76*`R!Gw_U$`WO?iy1PeOs>3FOXOmy#FP}KE z9DUh81amxm<7lTSA%|J?#m>TS zGB>{^(5S`Bp#MpJbnx@tTK_gcK@Np_?H0;9Llnv zpu#y-NhmJN0ZTv>YdY9H)r-{Fy)H|AYzLa%nEAwGQCS*(2RPyxg~6qbU=r}YEP<&tv6O01 zr%Isv*4KI~J?`S>l4d-ksIk%p5#XX!3Luu!`vm}^CPdEn^1 zyV^pn4TE0)p1z(*i@|WdYxCyrfR8F_wDpf)Qvbeh(ciGu-?2TzU~Zpj?OzG>wB3C1 z%`JU>TPFHiTl+S5NB_QW3$10{-pW)wq4Eq$D=ctF)ifaPncKO z_9(7Rl7Oj2xI-K0B~>Omm#}%R%UrReL%cWo!qR(3PcC;37J2#t!<+2K_AVj#u6Uq! zdVKKo`JLx(3vXTMo2wb`SqhDyPvh`?W~=1;oDOY4%mf8jVn>@Oj`}Q}iA;wZW3J6s z6D%pMa5mN^av7F!f(m5*l&aL#R#(xf(MU=~g(p-xXiyvMv?wvDSd;;+kPT*>Q8{eNH#H)!^TcU_kp0b_k%N|<<2@6I1?X$s zG&b2^Qc>fntetAA@io+XMx_pN;K~TeH3SIehjO!_)s13G7*4KMrW~azCe-8$#48cQ zwGFCrNqUqa#)7nnpA0wJEAH60ynMs?=oiFizYv*z?;kX^6n(ySbVFugnzv+mbnu?} zBh!&b!Z$TdR##>8czw*{*)K=uaF~S;FV*!q%3-@keAGH?pt#6|e2s+osH;@D^=vXt zm3twGX*BHnrNrbUr z-z~9H1w7wmYM_#v#oAZITJb%Ls3!eu>9Ijw5}aS+aagR#^pmQM)N6p}#MspCNKXJK zm&_Rq?ZMziY-w`DXLC~0%eT1?sO>lA zu(aXCrohlp;Qsdy%pAXWthBafxGvLPeD}hxsbE`wU^x_8Xjdb~kb3_>OLJ#7Qw8m};$Q#V|0gRG)DH{3>)7o>M{j56W39VAYYc zC@~cT)uVbD^NXTTNz!+!in*W*C{Y^xgp=HpD#azpI12BT0lsn>s`(SU{&L4Q)v;3J z$v5rUf>?fdHlZ5-Q%(Ig$S*qU2740uer1HOW7gJCo#?g$KkI5|35y1MP2>_?h zE_0UQ9!HhOqRA_Aut{V{7gI95nQYdD!%IsyYRB@P#7VrJoxOdR)cnO8;>G=0qT*MO ze7l(B2-X3eDPE?OgbK?9yyRF5=OGtl$JD8c>^cJs0FyqgnsCQ-YFuAVyVyySd2(zz z!HdyccQZ}%{WO79u9cy4`WfQyuIkG4(%e!XSWjX98`a|n(n(%q=9G0z7Fk~kT1Wi2 zbvou#!J*U^$&|DdG77fVQpi&9@S4z~-@h0Vjz4SZ!rz_!{hj!+`xp8y^ewhc8;pbP z7a*Jvv3j8g5v=&dv}r=;tXNA~WQ&loq%$W>8*=Iug_%=kx)w2?C9-H!=nQO6}EDp+P~g z0N%}Vo7GxxRe2e1Ds{Qb9HI7Yx$GS$v4_fBB??)iPr8h>qKGrUEbZKYD z;{r512U<3K;=Hx$WJtg=`7vwQdTU_o4SG0CZ8EpXdZYecZM?yDo4;XOuy?9Axb24d zOP3BUAcXagdxymLq8^+#hkq{|B0Yy{;`|mi<3{KXux)EGO=6K~jf5>=l{5ecp?&nA3^KT%tCeaA5ST&1acl%?b}DQaJx;Dx8mhI4;c`*8W~ypIbLhJke*~Qf zI~SeE!oC@0y`^3m4Grh9XH|iYg2eu_R&w-dT*qQZ7qNwGatjsWi0K-y$6Z=dP+wS| zlPwPoQ1V57TEceXSgH8d(ab=(v9RAf9vp?7^F-H>ukx7Mb*$1i*xekoT7sn|?lE^s zZ|^g`_U+?eY8-G(Pdf%0zcjwRv!$hTqQ3g+r>pC?y|NAe^ZMV5fdC`Nn07F|3qfzU zS_}Uy@_Tb2W;q59GqFU*F6uC&MV@PkA_awR51ngi@*AY3m*;Zk<)I*500wuiu-)zO zmzH*I-7uAAGZ@m+j?X`ZK&d}%o^JO?UypmSBoAuh{Kp?)o_9W(ffO+=G$nK92~)$w zB7&X7J%Ch2!4V~tGavs3asKja5-a$Y3p|2Nq4*Kd^+Hjol~W|m%7-8?1FHYYKBLRf zg4N=#uw%0YF@NdS+m^oW$==vAzQ6L%9R6xkpzW1{Ix6NI{R`E&t6}C~)Yc3P5!46Ta=s>AYs?i)zz~YXJMdYt?HUjEM^MvCz3vd*Q@n49d@xT9fwfes3xTpyXXQYinGsPPTpQIeD zOhhjh1O2Ik28Od#Jtg@rYz*Lj#ufxy8&!*e&g}q!=`;y45^#41^aN5Fz;GyC0-LJF zTM3^U7xTdrGXSO}M1GTXh2viVPBPVBoe?7K|p-UW)AP6jJN>c64T0el^3hH8M|G{Mp(P~&zm z5IoC(3|e4CJ9+&;lp2+-LUEyrv6jP$0>+T>e7d`aC~^*`#lf-+7Ol?#(FScq)UV!h z%j$tMwaeQZD=QnfFV~*=X>obE{nS3uzVFnjeKSj0YiF~TX7-)hqK5NF8lzut9KqXY zZZa`9O>8 z|I6HafX7vxdE@uoJAI@X&5TB)E{qh807#?q+Tl5A;QWyuCvxX7}N6ELwU!5D(^ z(oI=9cMI|Lg;v4(7$^Wr!KOj4|&ukQTnoM(ddX`nQ zvg&0XtUlyN{Goa`cij&5EY#3M_QE!X4 zT2l54pkSt_nNXH5`7}bpaqU@Ci3iRpE`@=!=Efi9?{mZ)Z`IA)UQLfu)3$!Hj($G&LxEx?-^ zfBe_yzYWA;*+nJM1=wR}Mde3R5hxdkbS|eigh!d`I=RRq1tr)F6E3P^1JQL@q$$*t zktD+?-VmZ@0aOhVOl4YCuEd&I4Lo8Sd=UV|e*{N{*U*u>jvTpbdo;=#2$=FW*$|ZV zDfw28JOC=k7tLQo_=1c>PS`l71EQd8gP{0nPJt;rdKsz)criUS{?Gr+9>3v+kD^gL zqP%==pNb;|oVUIKYL-hgbwTLiq3B9UUmk?cbyDC8=~|?6qUs%0yd&hK)NF+BI~iQ6 zih#-!p?U9I(kl!Rtc$}pKYc-X+qydcm)XtRFK{bsfjul9DleByx9yL6mesId)0`!~ zhx-8g4A_^mg`QZbTr1IG@xJo3V8Q;7?&$I(1 zurw5mV7`gZeImN?E9JCC~CzXZ}eP>lW&Od(# zy@>hXHLQ}5lA;6+kzWL%w~Ghu#E3#-+_RjR&~gvnSqt9z*fn1!143^(F|rn;{94tf)591ZhwTc^ z-$|133su6hSZ`IJxTqi>ecmM!Ua1ihJ5CLhyJIzbaX-FIPYbGpGc|xfW6oz9%@c6sId`xiFVuK0TTG*>o zf&pq9gve=>#D{qk1fA0IP*@sYu>wJVnU_nqpendsrX$B|ea!lQ$cSVQHWt`(Q<-M+uMz46SJPG3oVYiINBC0(J?+Q>75qbp+mn5({U zYU$>)wyp0S>1-)!b=4Q0-9L5Sne3bO>pTs4YicV8vz@1Rv`u*G@`l3|ORZL@Of+we zMv~kQ%a$JdTX9WrNneR{=Sj9y3_=z2jU?u)22a`|?>x%>@(7?WegjNz7~25%RpeT# zSCB*1;vLX-6(groDpqB6q~UN@=W=Fd@Jei13~*HC*G5HXy+1`dLrEf>N9gdzBv2ZR(Gp3D758i-1Q39Q%ny{$40v(U^6r&8z zLhKa4MUFH$4#EZ?mMZDX0l%-zTZ*en9J#p)n@Jf!QVaDFj=b4RaX(ESopMS%1xP~Xf@SMM^{DJM8}xXtkYTbh)tP$y8vA@0tFa)@>B3< zHQT)9+k-y?QYj>_Zh$CB2rAo7TT8h6kl;(6Qbj^Yfu#hB-VAm5KVQA;dJ76(J1y7O zzOp@fy!#1|qSxdna>5pCRSxTs`{cLE|A*ZmZ|3ud1(@p;B;!Y8t)yU5U>2@{O;MOK zV$)y*qx25-9;hZtBkA#QPKVP(G_9GDXOBT-L9k3etC8P?!2c?1l#jByF8N=p#LLR7 z=FTViWo{oH4gNtkw=CE*^Epm6P-Qo&*y|_-Nv%akYMsGUMRh<>{5SnKBYuP1gHEo{ zLJq)UOa^A6YPY&c0I+K%ViL&90XrPuhy}6wQ=bbQ)=z-owZa$&>2Q)LA}a-gE4~RWi2(G&V1O(IN(qTYB0DrB1$R!U8DZkT zTRn05>f3tTTL+}Dv}0=Tc}KG1S40PUgH_M;t-8%&ceF<><12?om-G(ywGD2b7?&?! zQ`ykKMmmNqS*x1muMKv7R{FE#_<89dR6K&?5U1}{W09iu!@`#o(AdKMd=s{g5oRm| z5ER5xet`V1LC}Y3t6G6(m+V@}OFqGfLxpw#2g>wc{ z&cVT}q7bL*!g0vmv*Ehkn;zJ>y8)=R8zSLvL~;G#0|V=GDmFh*y9~PwdyQa}30xyv z1mu*Dvqh*9VV9y^l1ZPF@f6LH3=t|JSKLGj8(d(Bpcs(a=?gBH-hOU)a!qX@P`hR_ zd@kvema{hBOkusln{0A}ZR7CfvsSV@b9yRouI#4uBz{f$>?Y7S>ZD6W%ASCkd61i`Dg1{R(7@CLvuxeILhqCAG$pqsYka?02z@ z!|IY=7L7iNb?oFT+FMv^*}d_BO}p#DOg9&dhwJ!at{-?{aJ?wX#iE$Qp2}WU`@m+v zryua4KG1H+on?x}z+YCFiz2DMOo~JY{e%FF4k#d}F!AYO;zPxB1rV5Bip2oJY92PV z*0K<_pfrntx~^`on~Y18x`a3YlJ}AGPKZgCCR*ScvgJ%)AU_vuShE!y`husv9;j^y zz6R`^7gCmb{B`^KfsMOa%PThf%MF!1Ir16oi#grOk^?`xR63juYKV9$53doWtp^!) z$Vg2*y?rHYLkOxwLnV`mx0pbibR|gvVuZtCH6z}OEAKpShEf#pf)30H%fraE~+x)prXhkP%LG1CsLK zA!~xRc4yA%ExN41$jByyx(#L>*)ViYY}vAyJ1gr4(I{WMWFNAA4ooOOd z!8KYf)l!?hj?%XmJ$oz`$j!~nU;+t&x!zoFDZ-}7?ttVU#+gSK6`9nbRSEZf5ykmp8==2DU8q<91*CyWdRK2`8x}9o@6Vx zZ`!md7-qj-ZV)dmpL=_3TP(PN{RK#<*vJ}yr;3dX+>mT!;vzOO1!almd|EphT$2mz zWRcf8ZoTK;spW*FExnh(4VEI8!Pb*xiC=tb}9%uvK zI7%!6@8AuEO6lHhVHQ%Uv{(c(R+2KscJ>T>oh|*=ThV1PF<3pfrn*kN5UJ3Z5cYx{ zdtm_lgSbaI>=w@sJ)935u$7mYC7lsC;e=~&2tsFhnP}0YECl?5%5SRBSwuLG02|VI zK&;U2&nRuS!jj3g9uym<+lA;P#T^G(lrA8n-y)|Y9xQnxx+I?BJk%J5eWI!|T;cZ> z7h&*)4!6tib7Y`lF?me6G?nZ!MV=6JGsa&KesC<5%~JmQ>{l6^DuPQahSutq*fsv} zCH^q$kCqnxYeRwO4Pa5Q+gCw*slQ!bCBh$7E{`HSq^Uw4EoT?|>W#9WUF@x~phy6A zmWEFx$18O2e9kGprP02Em^A~&?gDswU^r0$n6FrX7-TEz*N{pH3u0v&B?3nv)sjOk z{iLxN%tnh+OtmJ{r=ys`p^B_W+gPhmII#0esBX?8=%5Zw1b zGDX$S&wqX=z3Kyj`nq5+PIkjW@(pPM~^`y9%NrqM8+I0M)c$WPNWM^ zjYlQ^igbZn6uxZ@`*Yk`l7T256oDLO)QA1}0n%h*QG5hr$oza!%;3+QErVZ^kAi}Y z;ZIf+0Zo=7J33#TQ=B~(gZoSn$OMoY;sq@rk3#*D^|QdVLykzA24I9J$;c~1%uZ1N zNOHV9p#VG#f&u|1ibz0+i@P>);ZdG_RZ#%eK>--OA6Cm2E;TAVP?c#YHZ2u40e?}U z4V6X}*c1Xb_{9ca(*oFNo0(?I!^S=yHe9J9S!U6*7ln;0;3L@Rv2WAmn1Ck47$C(U z=7UQyTmZc=37aGG?-By_cSwYZmL6w1t{iRJ1yyM-`06MG>UD}h&4I(7;yMx*5j40O ze<A(~yl->wI)M#uawpgj11$rh?j0cH* zYH-EEvw71~k_Pa!JS4CyqAO*%kP4ANjYN|?#S2wEG4Z}VRy({r+}zpTe(rz38wD=E zJXE=Rlg~$WhUI5C!%ymaWb;IfStly3vK6p;vY}HLJwa?1H+m|S0UDn?F`CXg$RaOR$RMr=Hs+yXs4~1&H z`8BQAHbtw0g<*50eRU+hX{f8VxjL8|FohhOmJV-R!@4Uwt(6FaE@{Xxch}YpWQME} ze^FDWrI*%G;xSiF@mW|0Nt@`8^|%pW2Psq%;PNm{DQh@^xH01FoWxRdR?b+V`=9gphGNiq`h}q___RjVzt~m1Sv)_@iBGG6`Y$?0A{Px>Pr4WOA z=dMGIqKnE91qB?VdJYt8cnaGo#zWNf@3x&*Z{L5!U$J^3yzfnJJ4GcFgKcsR-oCzV z|1f(({%OrX0>>$Cc5&M&9-n4!`vlwRo5yb5=g1aCch={s9=f>W#!h4>T+_VVY8_~z z9`RiCOxR9?T<)%;_^7(dEKr*?)2YG+Q%$F#Aj!?R$m__@OEsOMJ{(f|cmPk3hwzZ` zl!9eaY^U6mdd3;o4?z1xBPiX9rU|fLsA5d&t(m;Vud|Bi-&^{*rR$cm_|mum#el^O zy%pm>E63}$Wua8^5&5$@rS9x3v(3S-oqOXhXIF7fPwriq6qw{hK~GLjPeB`psbWK= z`aO1vOg!0!3b1~>4HZ=_;d_MW7(#3f^R<2Bm2VulviZKBedE#_S>2%g((?aitxrGw zTlvZ7pXWdYtpY8lb)-u4kQZH9W&?Z~Kyorkfkvr~0(seJ9tY*l#<@0Uw%0(+htgem zElpP=lvD`UzU*eQ0=IM$J|0j0a>a6RWXdK5hG?;Pq`I2X+6dK z`&qI49(zoFn^nkXEMrCe73^o_VYy1BZ7Z;cGGLcSZ$x@liOq`Q5!m3EHU%Pb7aQ=H zJ2T;8qdF*=85T43&($mOGdxQq65>GTSr~{~QzAl}LLnq)u&SOdEtO>(zm6_Q+rNIA zue{}qAM{6~{dKF?Uw+YA}yOY=SA>%WskoN zLvynMxG-He!?E^Jv zK}GIJiss0wD70tW{+2C0(E;jMFc2;9dJFKfe2#K6D2wo!uF2+=E0?m^Qv8*lS-SGi zs3TZZR8R;M=Dlioe*=pEFFl+M2&n@?F}6g_hNPG}r6?DF%kp0j{$#xH3wnE0cw)(j~l>M=T*Y$$=N|BkJ3Oqjb`<1 zRKtgP=9rSvqeMV5@3wv8gem>ekw;PK|N4i=ckD;WWcr~Y+CTi%>b2iCer99CS#aPvij-IY^%n6MInZLn~U+dLDZ=dGM7p4 zJ1Rx$cJc`;YLUwQs9Zf7E_gj&baFD$?p49hcQ0H{in2!-!MBBGHeuEMyQ2P*`?emJ zGMh@eCL_`5WnHUQb>U@cpYE&M7GbsW4@HL)-JEb{e0^WvdU|nu1stCfFtUW`TEz!e!Z0=)C*xIQv&w z7XNAA^K0kM$89B{8}OmtS4FVTGz)#P?i>`Cf!GFGfoKtt=kjsYqXYH`5~@`!tqhkG zGoijV++5il@D*2-R3PZRh!v^iBPAt{HW1%IYMU!EQfpv?10wO6RrT#`AL(xGZJF$h zZEopp?H*~L?qAZ|yCl-r7m3B=F}y4|*tZqAI%93EqxJQpt!-myeYLf(6U9m%?rv`G zrkDKXq3F_~p`}p-a{zQg41aPlN5TQb7XrdSdMrDBmSBSe_nHJIba1T_fjS;&bqWsP z3U|Xa_%FL>}>hD>Slr=6HrxMbODBC z0@btsBx+hFloj}#l?pYb*d~f8Qy}H*5H*AV({sbvKUO(dy|uh{d0X4^ak2G`&hD*C z24mYRd)elLO{3keO2_hw=+IC!wmA~n9P2&1u*$Wfxnece6?p=p17j)x?jpt<792gdIHXKYzn%|ORJC_TNUl9!&Yq)dAJ!T1L- zeknPAt1^C-W@5BJQ@upV$K1J!2oYl6i;iC-oa*>PjcTd2RF#(6nj{@WT#qeJjNLNa zar*9k+o$&Ln_Al6zZ5Ua#=$_{K*!?^t>*!|?4!DkA|OIzs~FD(-TEk-oh+)i6k&B<^JACfhNm z(%lsU)^u)J66@+7X`9|!lvmqE$h2gzJKj=XSyR^(w}4V~>?$rDYmBU|t6Ljs81okI z>Hq~{-?UnsJ>~UFJL)RBi<-&gk42|pzDlL6bm1!TjbZe04hQPeqVTxz#=V4CCtE)_Q5|Fv_*~B1?8oA-5F1<~+@U~k{tkj3!48Fvel5O%W2MS5_ITph4LJ4!zYaAw z>AI9-SK^+p@axJ>I(99tdy*gXopkJKJ{EeO|DlafXy*Qu4Yhaox-OSb6$Au@Cg1|J2h>67phX^(6UR4aUcBl0%{(d`O3S_R%`o zh@c=W4=zbuhq}?i&PNPedt(z8GyHR6dA4XaRxp_Q;c|dgAIV$>v%zRS2yq>*Rt9m@ zgy?e8q|d z%^J=*ll^nUe;M7Dmj0#ke;Zl;Trk#GURmVx6*YBqG?YX=O%~D6uwc%`>2rp{#qx4Q zQ_7s7ylQg>W#56ylQdV%TyHYq+rl{)ySO_q7d0|I&78f>KFlBWUtrx!nd9Wkm?OLF z*nz&od!woIaCQ&5lP&;Hja5`-)ou_$ z=r(6#KB(AbJxCq*D9r@X_b6C2ir{?2HwyK)p`HlBFA++V1p^zQiVOOz zs%QW^0539y8aeZkfY7NRysfQ28Yn3a1d57R-SPUm>LvZJaCA?A@<>~4UU6|=UU6$@ zL!{fVBwX7jf0i~w5~iRdw{aRqG#d6fg$AY3up^*hKjkzE*5O5H)TKE7JU@=8#6^zZ z#`izbu&R^x{}Jr}pYlCkoxaC0u1j(3d48mnkA9&xw`-4`i@P;+5fU>ML!_n##ciS3a`0wvGJ}^E$ zK(C6%#tOXHd7B0<9@w}Me>TOIR9{@RWJ%S<)k}!pF!0y|?AKBU2t>L)D4t>uTE){? zReO*%4|UBF_Mn@NTz$^wz3f02>s>dnOuo2_?s)_5`GV8~yZkBbL959Kt=K(QaeEMw zWIaCEgRJ?ueNqBV)m|FicYG^5x7Jryzf27GRaEuCgNv>p2UdQ`5y z-%+w3!@xMM1U|olx#bJ62`Mxb(&XYcp|~RLleP_*GoGfDq+Ld}$NK`hg(5GUZn3Im z<0$4;-q289vG-@J=yTD5adzJ7?m#Uf;%fRfbS>XxT+!CO8e_T^uz3<=I)yz5!$^mV zW)A{f7-ue$Vh`H)yNBYBe|Z@@Fn!ty`EQwL%kL=bc{Sg|9k3XGj6F!5EU7kpvPyQP zI$Z?*nHHHYRb)_1bF6k?tiK^#URPHR3Fh_XgX=f2^WwebRkeX&%`ZF881BYQNfx;b zV|f*G&4P^-wji>PlD?R*1;tZpLSP(DWK-nkI0~JG6iX?KKW!D>xjjCmkbV5a} zfI?C|w8?*9<;A*!`q=Qsp>R`ExT3j9|zB0cpUrqHjYOv0_==IPp0%CYA}P3YDaQZ2I{p>0 zfpnXUW>ZSqW&mJuOtZ^~sl%sE4w|T`4nJX*sV7rnYIsTt1qt?y>iYC`{;K+%bu@C! zW(Xq>V+QQJ-lkk{Q`Nc^!?EtwtGjO=SpVzVP_PbRD|I9vCHX&Ow1xaXWxtcXEJFDt zNm~sv51^A#M^X@mF`jrmiSPq>vKF(~&{ANNM-tw7?jTW|TIu#0WuqtUH3OTjOoFrDdUwXNM~tp4wobF2B99IWN1uIXkbJwtPvoEi1!TThP+e(^6cMnPInA z7h}`sxtyj#SE0{W=qz|UfK6-mcuaUBeG@Ra0{i48E<-^VTe#1#FRsA)z65$(EW~3g za~8Gdpb#9&5KkrSI>|MLR*E$y-yXNlg-W?rF{|X$%A#@|}#u{wX_uO{|+-1DMap%Cs2Gb?i#^Luome+0>m+ zJB(5QftiUoDGd80xeac~m_b#*yj?8=e;8~a8^`fltW>8vTg zk$%3h9OQ_``35tC0zClw2Ij(CBzv;QnJXcCzC6E~T}*Z3Bv^efbeKa_pG=+h}x;^!4i5 zha@n0&^p}rarM5)(?TdZ$iK)?&;jI67$r9kPpJ_o_d7KQ4e2!SP*WC~13AZy)5ngn z5hQx=sg_n(&#sj&ntcf4X2ON@SF?AdT6nZdVuj@ACwC1UI7If4eK^M;7+jLUQ>mAl zVRc~ayxH09^6y#kUiQ!DBL%2h2IptOUfk#36ZgS%7rT#>?&D#s(dCx*!ax1{?X$Cc zq2m0TeCGM`E)K`7^Vf@E%omMDNoGfkCI$tmk+-qHsFYgX`w%rI7&DFN%gx7Upz$&C z=HY+-#y%&%&m0l?H>`MB;?o}Xokk=Sg%Nz9eIOXu0LFh&pII6!LSQJ>poa^Es{RTL zkPw7@PDJ?Y3son<@9RlARin zIQ*9Mj(e)*)^XQU6;MvOJ2fP_+k}4RFAc@ zcle3sw#LRb^Qlg{DqdN6cX6XD?r1E%yRtGK8f?D1xi((ge0S4Oh>o?6m`(kyCmq6? z&3skxAz$Jw#1(N20sX*El!QZ&c~EAKeA_%Xdoy^O7~#Uh?1|3d!7`xZmol!5!po8}*2W8xoxf#{D(iEr81xNyn{M5UApr%M!p z9CQFRg4BZ$2Y8`tWbLK($K(N+SOg^O%+H-Ber0?e+aG72pM4U~0;f{uV*6Bc@ukg0 zU0QWEAi=Zyq$_kF1j>ALCS6=_fusfeoVh3QoP5eX%yVV(hzl~oCa16iUoi(>j32YG z+fmje-=2@!1;~TUcKE0+4{9-L&ka$7VET%XDEj=_r!a<@LEU2*!xMn;_nva)>-B|h z`s{mO9nO0k)}BJ0z9f%-rtxt&pikRb(Es6Q1h!M`M(WTg*HD8lE+8X-;^z*809vSJ zMvsSG@%&u7`282ApQ>qi!Z#nEe?PMHJ!S6dy`41otPIm4bI01I&Ykxic3Pb%P@z1F z<{n8ti>fEnGf#hh)-L@~dE|EqsZt)zLM+6G0Gx6NZ?gB<c?NXPnCmODZ+!EGER$h~$)~c#23F z4H{0f#)b}*B7%=eiSx8cTh9o|En;SC0PyxAYum-&k-tBT@X z(P(LqK7rTE8KD0pLxrRV=bptP7^DaP4g2Dwi_qUe0`2n)gTjb#^|yvs7J#KE=^skN zXCUi=Mx4p?c7u(~%iDuGVxay^tECTk><7HWQ zSFE#bNu;?B9)ZAMaM0(aD!piCZ4ms-PnKSNS(5Z|BA29mu$ic&5b>4?`j~zaqJO;F zSuDrXY?u)?>@eE(Tq>cWw0U$Q#^vr5PY0inE(qoKXzr( zfR6GE5#VotfUoEusHseGCB9QFg(1WO@-J05v+|6c8DG>r{UK}>8DeXGSy_H@QAt_; zLeM86yhW7e=7!?&O7w{m<*%3J=a+f&^L_L4vu5CbOu_$|r-1(<={jgzv=6R(fn=8+ z9Ph(%zfex~#0$6(K=CF7P7pBcWLgT+eMb=zB}|x&phS9MBmiU%ln3CnNJn7G;5D8S zH_;n{fy7;q2JNmzOBxUUgJ;~y;d_ke3kt`D?yw7{M;Lq9 zt|%0e+yS|_8Rt+|K}a|~wh?3$F}Knbvgh=!EO@5$mK{!qXtiaCLZ%h{bumhjZK0&9 zB`dS-VrFJ*<}lWdNBnwuxdbIsPDyd$S_l1cn@cJiBVG!S=P%+hXuoWP;KX0P2)dDB zPK}Kb-Ho2RBw#iqq)%{1yj=m~C6PGgL@{6zHU)S+2 z{AE8pew<+bQ^X9`2)6*Au9`c+VSd%a_Uvv9l*wLC-m z(0sRYeCB4nLl>K)%ul-aB{=6|_;2ajd-0P&x`D1m(BA!!o8HCmH_hEU|4YfBhi^jB z-sXQvz{WX0!L_x>Q3y%b%zenOy@rqLh55e;zfyjm`2fGuak{rrx@LYe-f3*TsDyek zl7Vq-W*4jubWW`hLB5B7{s2zNLVUu*5j^M-QF+Jn9#zL0q%qRncwbcgY!!Zg2y@65 zyh5$8R2X9IN*(5c5~w`I2%jmK zXJbUD&mvL7KQ-ho102UNi69&#E0|2iNt-!SG8&PEVzDH?AQ3|uNSAsa)4_lEMzN)z z@*Wn8dC~^r-K~r(6B!|$#$jZv1v)$G>wIN`(BIQB)H%4Mxo&BFG#o0c_0>?)a*o&U zwL=gNkho7)KSdj$h(@DZ-yBfnxInYs9Z-x^Dw&nizNH_N%?+`kA;|vyQ>}IDmas3f z7MIs#E_28)vWHH*;0;%nW|vlmz1fLR?Dhl+e*J#xJ+iKOb9d{;7HQ(9(Ob-@G?Hn) zb^P_MmxR5gm6fI5u)In8q}M1hK&+Ej3tQQ1z(I3N+TlF zA1rVbWY{y1wV~wHF3c$g=eN8mr`|vrpbNX@J+}k(@yzV*L z-#Zet`ns9J{Mzg0s4B2Qdgyupke-9wlUq?UovJ6In9Ly<7ZIrsZDR*wT>)jJ08df3 z#a7;JQR%^_P+u!Zua6Wp{%Ry~fWL0(kXN&p+U8D({#bvEeN~ROF?sIoj{DX`j%@17@CT!sJs_Ba0a1i_bRJZeh;_BY5j4!^68X}_;b`~8|%==V)nkH?kYIdud* z|2OqPNJ^%w#d5Ld>Gqf>xE576N~q*7r4yqy6n*mEMj~wOl$o%}=J3<%gW9+6q z3T@da-As}Tet!u#n8{@sTs!|T;v9|wv`T!~!~9}t?nm?g1e@Y=Ap{A&UVzu3xJWb& zhM)!xSk0o*o+%piM#BzBGKk0~>5(=J3G5QE74=Z->uPJN5$i(L1yN*A{fG1%ASzUH zuaRUFyqY1R;HAhfUeI~BjrzJHs|isqP0e0CjkS$*4kOqF*J=hPN;-ADtoaJ@yk8J zzkM^vj3&Z5JFu=-u)CZ_``IvIg1g$LGc2MhCtEbSOvW7`98QDFIpuZ$2yh;up#7AP zh4|+zVVf0HgJR-&k*I=LAKk`fcOLv--DfP;%!D3NC_AEUt&!%YhPsM!pSQF)K?#(4 zl}cf1xUmF;mgQ=C%qd_E^IM9(34f@na57lrIR6C}bLhVY;oU5kF9!ajX8m?jG8-Y( zN86)qT$Zn%ThD%EsHz%zX9CP!xonWl`)DZ)G#0Uem<%t~S&3_#3ovu{bmK6 zyewb57zCCb!bKMy)rfL&DyZux*>ZHb_$ zmgIn4!3O>#*xk&A$q>S!9;z^kEhDiU=Cm>=$|mY`P!uvntF_e%D!1gMD_yuU3re+m z^_1YEOI-^u9gEdeS5cR!#`>yAbp*8pfKG*>qR^t;#e+@jU9eSCRgF}cf@6$A`nZfS zHW8Lw;z|ktqCCau0*GnH&K+f^qqS}+AEZMDI!@)kpS zaP&l`4Q!RFvO=ZGyh+7dS-BMEzhDpCwRHEgf!*y>>&F|qmdAVhR>>b}8aZFa14Vv+ zQ8xY27LTE+8qygz?lWvXHrU+-twvSSX*o*@3(Cq03QDn@pfUdkvMtq>uY@+*2pY31 zw!=zACnXfP%0O%p7BoLMCsW6=QS1sOP)N`~DHQN|OS0hn0!)H_1Z_JEtPsG-c2ajg zVG0HQOyH$}OgWVYa~oW)Ru^D1GQ4ud(BQ!GW&M4<3R7Mh721XNwpK!`1lT?qW)+LT zOf%(Z@2PYadWR}DDeoMHP(%oZ9`$|Z*)&)(n+nXthot$ZKP3N2J*s}-@MX3#`A=-Y z$5dda1Dw98Kvw{7`Uu;M;~>F%jW8-~gx7ovXvU+lhfz0?L*FDd*4I{7R)n%m9R3Y; zHC5sApxeY~A!rp$0tBils>p^_Fw!bC(RyJeOde0G2kS7kecRS8XH0IMIQ_Iu8^^~s zjILj|cFpQlaatd&^};3k``4%WQ>@RYSto7X7F#cE-9DO5U$Fx6>?`dlCzo7Y z=vspUUmB542rml1ZV@2#*r=>%%7rAnU;~aSH()L+bEzagadlGku@j53xtZN*vrlQF z4{cw;nS)E63oivDKyBzk$Ugp%A2f}4wqii}$7CN;_=t|-eF2?ME|h3zkI(MHIcPa__5>jFsq2rNABQx=S8q3!a6|W2sJS!QN|8YCWG`W2@}h7Fk7ZgH5)^V ztE~x3U(^I80M` zZ;0(DG7BH(-uXKaH`2p7&RA@H4a;Oh0l#P%{QG7={08@^{YPeiEIs1QN$rtOX?EW! zrli~3y<*_-rr7CC4X1UU=v`!X;XAk7f5x4AJnQW14vgHt;`3{>)cHbx{xkGO7)_vq zA0;~m9Lr?czC_W!DQyjU`t8YcW+(~+`Z~_NBynyQc@F5@M?~eF{IIZFy^H(up4HG5 zghMbA>hx4Ko#fLUFav8LlVr6bW02O=!-yOsJ<27kMjuIbuq_h@#hd2_qvD@tKiCOp zhWvF{q2%Ag21RjB|CgRKbCjN=H=X!JRKMlKMQP8GbMTzkFrO^J7b~?}(e_)1T*Za4 zNm)V`&(y;3jV#63%g{n$%S4yh3Bww<++9n;n^CO9YW_cSLu7k` z?}h7(LFwo>$<}d{d%r~3o|G|=Ef#$#C<;k9M82($18fFDEfhm)0>IP(A{D@11Q5+V z$$|X(oFytX%s#wx?mzHd{$di=itlLFg)#iIf-f`Ab6ntf1AFArf5I}$+j0Ch95+$? z7Ao{;5JI$w628SV=XFT=#t0C!6f)u(wq*}J?M+XP{3f1=a4<`6@9f`Bx*tptxF2pR z3WtD1B87wDe%TJXpAq--MDQmfe{q8OJvq$3nNMK$Z@s;e1#UOoZ?+8k`!XJL(xp&( zI|7C%C{Ee}>a&E5))X4fgRCf^4M!2s#!omY+5~hGUN*0LwoE)UE*{!CBpGKv8p2~> zmwlV)-~8{OBNfLA@B>%?{Z3_E5Ceseq&!hlA1AhN>y~UC!j(F1cRa#%LGl3R38>P~ zZRl`qqOk-@?!n-W=qB8Ss+2)tfu&>83a4AQVHmxfd2{+~M@C*|?ny1`Gw`&4>gJ`V zF0kqyfCKj>_7^#W{bkOSG~|)y^*xyHK-!bamSNTgoi#a5JIqL4VQUOAt)fkzi8e48 zE?j|GW;;^PVD(4;;>3upRtvn~7E8Bf7^Qe%^;bq+n1_zkrCF{lq>(8Id(sS?DKnrL zF`5N`PqRmu%h;oTXzPk0jJgU1YEQYZ%`ik%`>p9!f|I4SWv z)taIDm(+n*gu@jnFLpJtMvWETt5TRWp|G6+9)GM>Q6YumYQ+a^1@;1jsbsQDH7#5Xa;Jb?FYrcPOnhrh z`^MhQDJ;xENmltx^~2ZHcS~_zKV>1F;tz%ixV?q_v7hf_hfo!(psJtRj@8zzTO5XU zQ^+vu9QrIy4=BQtb|_*IQdDjx0iTzoWAK~Fy-3emfoE0nHA0MP0{Fb}SYmlT)gri9 z5r0sASxHfE#_A0AC_TuRpI0__D^UVIw@Wa$cQ7~V`TAMK!5c(nJ=oqCbMV5ot4BX8 zy}p~8CxhPe6AcG(s|Bv$)H4xhg&NR8QNFt@$E&$2sDf}rvs9$IRnr0XN}7LSX%cej zj?gq1?gb2A<1j1}&Qa$?ArCYq#Euvw{Z2}s;vP%Dl{X&3zFSJYFhg`Aq?m)BaOPMn zk1*3)QoyBJTbaE~*$-O!MbgKD%}OYMw&xIRVUs~v8O*mM2#|XVZerr zbe6%psPA(*8R$}kM>kS9V+2_Z9+G&ea~XQ8L?Rv-2oZ7P@fg(-aF6tLWmabwb~twx z?shEk==Ez2`rL?fci}E)M`3nNMps`~Mp*{_pQ=C2*_5rzGM0~&8}e=W5!Zzmx+3^s zzz@bOU3RnMwEEK>(fs^&Cyn)GRnD?f#WA3x8fXKwW3s|{B;=p(#!co^bYIZ1#glv|#7 zKN>x-S__3?SAK94?)%q0adu{$jqvzq#K&I`{iFk!W)#X|C16m=AoNAzwW)eN{deK` z{S*WQ$x4YoLLC1Ldmno^C|w7+4c=yQW`b@}9x~Oh%E3xP#&koOB@r&+UgXuBNc6xa z0Dw>~MW^f5p<<38loWbU4jNbHcthxYMq!+k>8(_zN$gg;m`2=wl{qfeMbyxSof{3c ziQW40U$<`Uu=F%2p$nsXj60LwLs9Tyu+11J>FucYOiH&MZU@rUZK}^8siuPz`he0IM*M9VGsF*rLi_8@{@Qkg);-Yl7XLN6!=NS)R6PYg!FJvwdC9> zLXlI#J=sf(M;NCBAuo*dOokRK+adQ8rmA2M997fbt|aAnt>PI)>Br7%yUu zSK(t&2q4F#3cXhgl)|2$oHsJ@DY8NKd~C)C!B^vnMxi_wAczB~Qz^lx(1ek(fCEa% zDOJGJI3aOC;(>4z<2df6@j&HXxDmJ?W=m={j%!+V`n^>42gV1!hd55IwgaElbq78_ z$YmN8uK4~Cf7Tv6kocLN^lfON;EOA03<(km!18o2N+^(Ae?D|*_Rt}=i+x7ELB4^; zij%+pX|DJ3>sis^!}lIOoVWoW!!+(eyi+ga#=tl+Vd9*yCE(~j`1sKV(CeetT5KC0 zK6{9r$i@&R8rHhk`<|%3Fz~ah^`C>ff+^sG_lw@0(f& z&;2iJ`}5++TN+2y&&VV{{ic`kE zRq3trW$Dp~7iFBZ+6o>Ix}$=1lcYv*tDrbs3x%-H?i=?Nl@w=t4+XsDy%UEIN4oqi z=Vh0z?(1I*U>5m9`L2QH*V-q0vL-$o?~4ZZjKAnzQ`t2<(v7Ji7gdSBGK2D)P!P^6 zh~OVJMEW7{OVrSTip{-q#trfK4QH%FT;{o1yYAV3eCqC9S+kFd|KY!W^7sVbCnDz| zj|0b)dpY1S$Gtob3>{-Pgdl9v(D!b+J|4f`hCB72D?O97>+Y%J+wa-6#dqDr@yYA` zw7ze`Mst~P9psz8&qjkDt{yVmsa5kR<(w23?L_yIK7&l-z@6!yrA^-)g*5}eG#w#u$gA}vH!-tw?mGFq(ufNH35??5r(s}5W!uN zRRZyv9!&NDbS5I4r`n0cei3<={+<1I%1HL5d=}rhz*WhI)mliyO8-WB;xs!38v1M4 z_n)4>S{#9l^t3Py9CcEzu{c5oex`oF*kR|w*eM1G^{Qhz^b0RijbY=n2B(F?WuO0; zAz=(};+~hGmJvN$5C;@pRUAm@s>x@%VgL@F2}*}Yda^{=fEn+(2(X~H6#~;7@#m;18)tFFo`0j_00}A9&{JozFd2{=o--_!GVOJOg-6+yCY{R9V~T<$sy;nP;b;c^c1o zcIug@%m4WP2OrRjRs(#joc(}5ClD)3@?nh`CCuNP9Vf=mPJ8ji?Jxb951H2G@4ruk zkL;$Gemwo+i{&(L;8HgGir6du4ETKYe7D#`@2HX11H8Uk_%Xg0aJ=r(@S1-Rf#CFk z_QSR8A=Dfs9rxN4tI(B%cVxn6^pS8ly*`J_Yeu56o6}|DGuZb$ z+R=>?BGgdaH{INE^ysRAFMm1q)?1%F?fB}YO)R|nx$c2@^n0u2*EZ||Jnvz*!AAI# z#Fzz8KS3zxdzFvbgye2WM)$-oxdf+1WMo!g$7JmeE^tNb(le_`H`^vB+@_(;8(0Am(=MJtP92o77RkWDJ z^&{h3cbz8H&J`lLf9tX@Y+$;>w_mvTH^Up(51cc&sH}`}2{}InX{GDIu6IKbQ2`|O0>jt{L#RUd5;{`ORgH)G=YP&)zJP0RN7JUEA zHjVgp^yNX*Y3kBUYFmcWXmIFZ%ts=bOUlel`Gxv+wtw z&YEB-FnjjHPabRuS1fi+Hq((+jonAv8(SOOwRgmv?ZtYYrmp99NSp_RD}=dN<|X@f zZlP5*xw5rY9Y$=2x~w(?LFML%*)Enn?XV)KQk=A#(TPJ{T4zCKM!wOIXQI_D_7u4b z^(Zzvnffy=ao?xBp<%+AfsVMXDYq)f%s1u{@{sFk<-v>2*}G#(S@1(&xaf+5SDd$h z?}2j;oVjc2>>X#HF}Z1c!|2-8D_8VyU$*_@k)Y)N8WN~(r@tRdQZeS`laL~bF*8d} zI;dex*{P1qU8P}=c+tt9{z0tzJMb-mcM@pn{~&hgZRk7=LW}U7n7x2m%;8eeYVk27 z%czv373v}=zWyLcs=-VtHOO^BqSLfsu`#R3VkO2(ya=dAy&i>0BT`ifEec{w|Bh=x zqg&M*z{9GbRj9GCJj4Xl$AYF*T^VX9Zz$t2w+X5dWI;O+hJ5U&q#6|9qROXKq8h|PIbP-<>C zN!~$%Aj}~o)+)CJ3()N#@fPKR$jQKsN~jO#b7ngnWcCWcz<~4(Uh#rAv+=rTULDtO zREu6rQxS|4_xQx&c@*DJs$HP;#k`tBO*I!OKfEUvQ><>ti6glH;#R8JjdXE6&ku({ ziu`av%@4=5K5qa9IEs}qlH_%g0S?9oa>{|2Q+XF3g@QzsOz@WahGr!beDBycy^<>) z3rQCJwlVo4X!lfgC6N%Gk}1x8GmGVbpKLngIpBcVqB-D3#HAq9!$CuevJg_hFP_^+ zRs}Mr(L)ep^jp{!iU6aa?(*j#b?PqXS%>26-N28tIeu7$^-A;tLLn3+;15OFDDnaD z!YoDjl!_&EKrL&6v7sH>qQlb=hK47v9v6Qw*EaXzxP~`U4+^$Ovp<}_UU-Z9N+=G7 zyxL?>6Ar0$gxJ1Ql@X$fPK1%_>+tx@e~z=ulFly-3&X<`j}^L~&p{ zlr<{R)`+kh^_!5UkFYS5K7a*WSVE5X0ELU`Q+Bj^BAt-j0b{Y8Qw5CC;N9wf_^hs6 z%IeJ`CU2fEAJ*?R;88t?uNhSz<1vfVW-%F4plequX9=(wsGxNdaX3lm9g8`+U(>9E zfREY1T$BW78Yo{IXKNRS<4G_~(m|`Bw(lgzw?gU?fFbySK5+1Pp;asbJx)Hj)gI_> z!?&~`#uSjL)KR0599Ga95;QUc{xIk@PDHUFTa+N7kV&I~AxX~w|FJ6xdH66D z3OaoF^<1S^kYX1>9f}9o4y>OZnqF7TVza>6m$G^pscP@3BbvxQ%7fe-RH7___42n* zUZ<1RNRnNs_4Sdo9a)=ALR3Iav1mg*x>%yQd6pUeqvoo-T*R1(f|#P~fm!jez-pno zFq?-NHe;W`HR*9E_Ds_FU>HgUXEGdjE43AkZV4x%ti95pDio6cTkBhDt1_@P*;yX;2 zUTP|rU!)LE3v!TPgfeAkiyvls_6)AxZT^DzSarAU9b?PfpH0!as_sl3Fo?;e!UXyt zG$W>NTu~wODaeU3w-ACdWkKSgQ3Mf2r$bU3OUcS&6h~9fp@V2*i8Dnb5EIc3xLGpA zni9VQ&16Xci^U6E44N;*j$b|f^aghQ2K<%xOnfi?J^Z6T;N|le(|s6Ifv`&%Q!xd| zBR3BT|CC{F%d|>nvp8V@T@>3XZ8<<6Q4|LO@u?^B6U-C*e9?@-a^ zu(MAogVn~H9I`TK3>$j+8$vbmhNrMEbE!fy@^^tDC_K#2K28EXq3Tz9aTK;1FfSlD zd|e`s_z+Ku_|OJJ&wTdB5SGf}k##CL{; zt*Eh)tf%l9g?^WTvY{-aKrIwUkv|Wa1?3cwnE-s41XbkK6v#k;f>oG}5hofU@v7&g z*HS=pBUI-{po3CHff`>b5j#Ku9?0`{`=(qWsF-8)A^z(!ZuuQjpKrav1x$ z|Hm+MRM?6UD|2(fPUC|$Pe6kPQS@NRRQq6=>6yx(aT)w_t|?zuNcG+DIkHl1mz`AXM!?4;d~(1 zKlJFM^OToJ_Ck_n{(p#-rHN3iEPqc6zmXq6Y~j1g)aHJr%xLz>L<|nkyF~Qw$8Gx6B8hR^Wv0JXf z3tSOE)b4DA`6xX)3!#OG&N`Ji2w6dU6hUC-9>eJ$-KD(P?1~jX=l@eJ3sqNf3A-P2 z!i2%=dr_0P09dc2`z-NzL4OwQKxh#=AS|T$0t;e3oTsBC2SJb8h>M@4hzOh#=PF80 zgcrKd_;jMtSI?K>RTICPw|Y=A1IuHpX^ZR03X4*tv}1c@qBR;((eVzIL!q-2+5!%C;HH2{-(|xOt63JLENt+wI8IFEVZ(;dk=5xjWC3 z)luYkDylCTC#( zNa9(L7ExD{Ai~2h7PlY(aZ%^e;mD=vk$@(pHhSopaz>(<<@4hA=h|QJX-j}Qlh~(^ zs{1sLdQCWVRDL)`T3*zsfM(oeRLJEWk0d=dEJ+FMqXKy8yRL! zR|ip?|6aPS0ebW#^yo3@sUdw3FaUd!9)5*a1=4D%;hf7+JLn>CZ91n31kNB`gKXxM z`a;n8e?0#f^ri3c7;e)yvusxKYb#O)39wWyiZut^@{ifOjuNA>#3AR1XUu&`JmaSE zd(qn#0;l1D4R39gJICJ|2O1>cl!+XY?pSA@3l(`jy=IZ?vD+||Ou|qn*DMgW0xzSY zS{Nfq)xtCL=zH(&+^IXNsuvSG@6+9t*kTi%J{Dx(Q+(qO+k?Nr%_)2T)E!E|lf2940> zvbh{t31HHG*Mf$qh=^G`O7o5$-NEQ4O zkOL9KH-?NcAE#3`in0XrRQ98Ah{tasltqiN*$g@>l9)m}(&ywml1i>31{;H;Fl_k^ zB04a(Nf*ps1`FF0K2CC|%&g9;e-C@|_Qc-*-#%;h67^a7-<-(TTMWr(4e2wD>a!51 zPc?~QQ-sS%&v5~ymWq&JE_xBgbpZgxEJ4Vpm?bF4q#tveBIDs@UXPEa$UZ+le&VYy zl3M&DcJ}<-H|FPQX7dC;J}0VuoGGBLmWlcaD|7~Vj8MOI5B*0afOGglUiERh zFTXJTyWj16er9vxac{^!r^ltyq|o9t33;C{gKpi&=~hUs&%`UEQN75o^7>3#eKbM$ zQ4eHX>fMZyj(hnP7h|2S#LUs2y#@Z1s>{lSQ5VTm1xCgnj|xQ zw`rJaERoGeN&Y}nYPfiHS-LFhFAj$s*??>mIsg~Dp5rPQns}U^sJJ=)#)u-)AQ9JLuRSDPNPaV?jIQTVO-!OWIJ7Gz$C#R;u>JPj5qU@rWZT zB<~10KC#WmyOa*tv&T*uDb%FdpBa)Mz0OV6C~0t0#7R?1?BctG={p^I;WzbKz7eMRd7E-Q+un3z<> z5Qn^?8JlqkJC#Bun1nKdIzb02$An4TsGA3hz^Im;SHhc>>zhd^3AptNHtKMCJY)7AcA>+9T> zB4wDKbHP?@GEXvRa()h0E3CdFa@Iasc?u05rBbsPg1fwE<}ZZV75vvxSu3>?_mxaPKG zWsaJGQ&BjP@pikYvRB1cE= z;%t6ezU4)B_-NTtc7v`lJ!eN@$?^FQj^D%WHX`VLH7q;{+hLa72HRmaaOCf|9Y$1Z zE4Lk{?wen7)1f?t6;`)HnS-@x#>3Z>>qd+RwvprD$$wGD6cGAi-2fj32JdEZHf%2e z6wJuXAVI2oIcIUd9O0mGt!4DzP*^$Gj-tOX(rG`bREbC&~>y2yAiCUcR=A` z^`zg(IgVnJso25Z<=W0$Ti;R?9_Tyr4}Y`aH;~aGf2uN}gM291$e&)*0qhhX3M52D z)8Qzk%869c-^hixUAmd^@frQD`}J)nzQ2^?%HhLv-+_7?=(WIw{OPbiY1*pE(@`=CXT@~p^xyY!cw=vdmJfA++IUCKD) zBdg+v(L1F_)tyO~*&j2zvQ(W=8E6RJB^oKYmk^OiLsgwmI-LQQX@hP;&>3_HFb~j~ zp^ekAU?-*{nH_lIB5z^po`MpltH zudFxkOY;BPJXA#as;UZ)16cxH-6#INLq(TG=Ap{`F4EBx>p|fCWbfZ8K&$BhA;bhH zaQEoW>EHi;=gyhQUH9vb!k{#f04+2E;BY)Xf_>S9*xi>P&+366rf=E|d|x(WU+Ud@ zt!%X(!lwt_gI;1U$#95gdvv;7hy1|*$J?90H&&MWCCh*_h ze3YwReqK=TFg^4CKJPh6({?&D+~4p28)%ZIIXUn9yzl$0-{*OhuLZ2uK*bw--*{v1 zhwn3^ypP#*;!93Ra>CZ=ho1aAw^c(+ghqz_fPNPI+~S~|!?x$<+%l-(%(lHI2Apbg z&JwxF{#EzL+sPj*WN^Lyy2{&!l~bhWE0}X?AF-FYjdwQN9%oezsh^*1uMI^#kQw8- zNLTarKXoN^7r*`NMSN6$_(R6&TpkzfinlB8Ee?)*A&GQ8#&knqk|~kqfNIW`SRjnGR9xjf%h8MdKm-S+LLMpk56u ze;n#2x{RS$_B*<6I#E6f*z)M)=p?q#h%hoV(AU!$Z)uL!`O)eT|9m-4iZ&8i8lqlw zMxpK~na^tKx4*1L@|W51lkrTI|8b$@o_CVBEh)gTIqd+Z)oKg?vfIBCIwqS(!tC(JiZe7HH?<)Gh{<=f|n*=7!^L7D9M9oN=5`m z$MWH%(n%wpTqvE=5TcmKXBt%L8-|sQqSy;)KL=OV0w`%h09RsERTlkbrzu<@>^7qI znoa;wYv|)B*R)8V#|IYAegQDng$0BCz1^K1?X67>wUNqDxgU*KM+-+S2py(171%ub z6)KXRZ(iQ{2bk;iH~G?8b*NMCN$ux>wm?00w%c7?=);a*z02`#efPSWRs&HSyScqT zlk_XLx2CtyKE4nY4J|p$tjj{_9xHX7CteZ*!C()XbymrYeT<|5s*{>+N7-(j^&l!w zB9{|QmG}!u*o}IK4w#Ze@|N_KIlI6t`3HIpZBeO$|AA_s@tS1haR947 zV@)!znz2R!gp|FGr2mTQr5DKPa1_aMkprE}i>Tn&5$5Iv$soe5rq{$bcp@Tu`z_q- zA>51mIg0Xl(uH=*wSEqi?&G)A$5!5QWlglD|5`hM4zYdN)>TTCzpWY1J<2P~M$sEf zJ)i^i{CX2Tzr4#mzfiP-*Z~w)a03De3j`1bQuf$lgT`b z?gAZV0Cc)i+;Htz*w}e|1G)<&H!xvzc*A6Jl6oUHM#EJBzZbX@2fU0RQy=Ri5cCQ% zrRapp@06E*tv* z$*CD+wtNYhEl7L$ajAc?zWR!AZC&{C>Uxq_3_bxB)6C^Q1U}*qap+BHxoYPGN^&Jii%j~8>A5s5SgNCOxY^DhOD@UP}BLQ|3~7S z2`M37m`{zP&5&5sSAfL1x;rz^a7u8j)J$hn^7lNX@@q8tbIRV^w7oPZ{4HU*0F69$ zF%h}Zn(S>6vd!5?1*2Io85eSxgE@|&06cJ*0Lz=q!a)dI(t1qO{Dq0_<~TbOHq-D4 z4>&_>`g*fbSbRrckVvGi6@k-Q1aC5lf@$SVMDmw)R0jvve4w{pN97h#2;hYXn?C70j~CDJVR~kfUgumZ9Rdj)}U!AyA3GX z6r$D-_yR8tlvz|Dru<|k(Y*pqYaAJD>ZuMb^lzG-oY{Ke%vhpxsJ`lw4dXjUrJFx% z@vi{Unyb2^6|I(1*OAf5i>(jbZ#lfJV?%#Sq}N*F+MgUdV4-0n_5o-JpC|nNJ};yu zVvjlsdSGhkLc(a8YBD0sHE2($A(Qg+kHTT6@6eCoi<*VzGFNd84r>t0Y#30_8lgfhJ5{LHLHrC5&YirHol3 z%A@Ph(JT`}qM;tMG@L1_v38cL5Y(#{UHRJD36GAaCftR}hZ#6(V{pW;O@GJKn(1e* z>SsP@UbPQgxoKLAU!>APqJB-{Zp5OasAS(K>_|-8xts=25cS$6X4bR)RJM%wDM!|I zwlzyKn~{iM7X_hJ80&~PHZUR48Sm@p!-}>ywxh8EY(UDk&G+QGOo$csoR)FHGWF{X6ot11RvukBBezg5~VLCh^N(?!RVZqP72$o%h|>)Y;w0 zepp(5$Xh|hP% zV@>t7HDTU}q6jWv8>iV28_Hizvv)wd^P=TiqMn_yE2(_)vP>$kBKFxyzCQ7_C6JbW z4f+!fxIiFe^rplFfgKT5L%%kiOogCi3q#Hkx_N;o^STzJDELeLOW~^n*-`4f5ET}A zsdqFF@E7?}M-sZW-6|BY0_u>*wAIUKC z!C(`~W-3oTo~rV@PGw)JO-wl5%&NXie`n8J&7OJt4gN#`VGV{14>J6s%h+QTQ|%Eh zNX)>&DX4&QB;7N#13@TAHW1nzUV>Qw$xT^kxFiEXx?FhJ<#8b`AQL8j#=^r?&<)|B z6+@)q#@RFf{c-lpEg0!9`4h&h4ptlP!~`%m51%%$&z}AfLM2DVZFC&r61nAc4SVMF z33$FPrh0&?L01UhRLYXWp`9k9b+-sRm3y*KN)nzd{jw)3!|Y3a7=CJe7G-ytkc&1b zb}QO6>x6ukPj%0ztRUP}BIW*TR;$>6VgOWrlP!3I1$ho{ceFx#QU9#F$nE!fBhl0| zjT`^OZP>>=FAt$&$V}u_QvL)oqzHY*6DW{Ri4Ugpp-^n7Ud>BHt|)LiPUTPHHsw#u z84(vFk)_25|5~B%^jl&LatH`RG@2N4*v-iD#Tg;wjHrjL4hNnD0|__NG5tZRCX0C! zJLlEjV6CHEkx*QSvayJZq9V3Ia50zBpq409ZBF!R;Xo-=lM1b{EdP6A-@b_r(|WOa z)4sz~ab}qA3O6=}@nhM)_3^Fyw+-B;yrul^-if)fc;n-ZasEK#mN4$^>2U{XC!TBM z$nZeg1{UU^z_%y@Y{=L_xg2@i@25HVgMRedvBk;9mNR| zHENttE@dAAe(LmJ@%^ZWDF&ra<9-JHcj>l*4qb1fc>l||uLpcGM*9GJ%mimTW$2)c z22PP)M%f4hB9qfp{9HGO^kRcY>b3`rIhodHb4snN~?X<`JU_&M+pjpWU(xoH7fU>b#^f|Ere5r z4n?Xe7z?j6%c17M!DjmL*VOpSYHOqGPVuPvx#q!|+A{USf~2moHs?xVI5AL;lZ?hp zK0vy~5rPgPxD3-1bbzvsbIxatC(IfQ4zs2iQ_GXgnbJ8w=S*QU#yqIl5n6X{%saZh zxOn@hcX0h#eQZzj1s9-^70pH6$ATLk$dG`lIOYDr0+$uJerW>__*-@*Hn&@F7f47^ z);FR28C5?6NUhr$~og)KfuBN2kb~lkXWCde3B3eNz6t<4|AUp$^fgKF@#J zu<0K*Z~li(hEFfQV$Ua!Bn=RDc%oNJC-DmW31i$2omkytezNas(>KeT~Jgj^`t8D}s8O1>gYJ8>;8UL`H zOlWegKL&1t^fU+-;x;<47penom<6{sjOTF%<15W8%tLpYOy*(+S-?b883PVN_2g9_ zr^_HoJ_*I?VoskkH}_GEf&4G+`3K)(Z$28=Ic@utKOVF>*7A>2KNeqF3MM_%jqGk^ zws9LM62+bia^7r5RXvP63&kxATu>5bjk@s)deqp_>u<(vkRd=Rj?6HNL;+@iVCGSj z6w5Y2IP07I66J$7NPf2@`JGNba|XWkO7xXS%Okpl&nzs=&xgdH zKBODYKr=1Vg zv>yPF*{uBF_rL#r9he4wL1nVv|NakuSo$vz9(r{gxRtjNUuiz16BgOZ35@R zi1Xp0I<~n;8-bpPEkQ~Q=`TDHLL!dr5j<`;!eSiDiP~8WD3C=UVF-VEO?=rob1*vA z8|@v79-Nu~zVV8k;3wbgpD~J$EDafF`q>8M+dHo?Qo%Hs#~8ylj3i$ugDl^efNKR% zDFEJ?9dSP+WWqtzr~zD0GKmMl$!Jp&LnCk%Hl-huLlFGcet2(6OA5*g%kWM4K48;a zzML#WakY9nROQ9*=IPr_suttnPK5@X9=y*b_(}bY9`*T~^rz7pSx?pa{gH@YKdaZ{ znSJlWAMQobe&m+CmkRh-MEw59-FMR?9UK8?isnHp7Xzk5BOE!<1Zl08a&4gx5VW2b zG;QFir>AEy2yxqlXk7aH#OLVHz)nC#bh0DtLUp17(ntdQ4Q~@f0vRi?B*+ZL@fDd` zAs;y$pnyKqKwwHN4uL5fSmgW1PvhH9A2)yD-U+P5Hz)4J_$M%aj~D@ZwN5}T0{K#j zDFI0!>K4(_fcow(;%3;7(6V#_@2eviP)`&V10rTfN7#zEnQ0bHkd3DCNJJDObrCdz zt^ydK)LUGb=XT~~+k~(fCiP0c$^irnqaUgxtSsKSL3Q|0b#$ssN$y%U+_?R4ce1U0 z@3xvv7nx@DE!8y_)YZJ}riP|iePd0#Zq|IU@&vm}-Zt3T4~J!2egF7KPqA;fylSkj zB2ZUSkQls+$cQsAh5Q5w`w966*nYC%aykikpHL`4{AUo~N83_}fhPDJc`6R#l6Xhs zjHCciGq%;f=jE0mRwQ14O`?kmsaWJ@Kp{dO)sj<}mwjq*>(kJzOW%u5SFcYcS?i>7 zMOkQ)uQYUA;d)dHeOY{v>-2u1_8sW-fpS%+udXbw4b+wt=lS#fnPT@GI(=FMuh44O z=<-@GizYfD_z`PUx_jzbxhK`;f}a%ioJg?Yx5M0jg3qh)JoPG7g;=XVXnNHdN37~o zt44V}IyA2tl`_Xg5?0p>9@qvR=z>fFQkaWmND@IN1&1mOFx^3N^1)!A=FYxU!F1}p zf+nHGxy85^<(TlV+-Ie;r#U)_%YgZ){V$}HXNgtS?Yru#eRa`a;ottIrjhCCd8WH! z_wFlBo{T78iU59B&o=KE8>4c8=!ft}&MWEsgToL@q_YmyfIQX24Twjq!wg)GY=fZ) z<+L5p6JlDlh}K;qIrOzMFFcelP6(zluKi6{>``M67~%%80kvDC@+b zl%|5aO_Po!%<1$v8B{htSe}>b%*nC|RjkT@eNSBuusSg}EJBK-A}S5PNiO+MknNz= zum>lA^6=HRzTxhI;*Mh9kgvD{&2&b4+v~ezxy!s|_{W* zGiN~C>mAI1TnOvV;z9QMc}(Ou?L?9#9~QS^ACew`EEz0_gg^wmoW$k1uagP~P_iCp zGNg;?p$NE9#3=w@k+^N?CF)CdiFE&|O%Z7mU7zut1_aCN6E!B_mawS!BjTeX)IF$u zR2G}t2NHcb$`7fEUh2ywAJhAoxGkzwvFFkE>_w%5-$PnF^-@Ht#8>Gu?qd~-gn%%W zm<*JlN-uXxX%iuJQ?C>CknB24K_|nd1I?E(()6mBLn?MdU}pL#NG_u zG%<0bHh5jKeQ7DaVd)n=NBa5S>G6cpp4<>$F&`Ny-x6UYF-Y`8!hl*$1T;D*$Yy{Q zWB^B&R|wO<3b|bg%t>np7?K)B`tNbd}tRtgs zN9G!+wYo57L@WTO7y+tB2f4I}#f2#2dkU71Na!8l0m*7(5dDZ`a`VucgQg{D6o%3% zm@i0gN|7FpUFLI0NDEEbXsMyXGP~w6MW1EARQbgvc9*t4VzE>Ai+@`hh6eZ{&rBox zl(MUFdewfRe7js!z8cMf;|5rH1n!p=ko0(U7_W{hxgbPEN0^USuZ|aPD5u4&L**+i z>oGlE7)DM9OWP@MOVfDCRJn|WE+tbX$Nqf!%t(t%F7c}v?H`xAV7P40CBx-MJ*&oR z1P0SVoEX(~k!Weu8_#i`>^5y!sq@4hI zN0eVeg;%z)`I9H%;mYI>PAKGl@}I(W3`aNLmR1muwk7Hx0oc4>Fdv3tLuX$SC#}=jT~@43}?L z<1$mEvd=QgD#uW`IMhTvv>M>wbNJO^|4VB9yVN3rjH)1ms z=WIOqg)bbO$SIa>kzBEB1wXYz=A%-Q%VDQTIUO<}P5{JRq&ewUvR1OKiM2u%CQF{q zlp!@pjEN2tSQ?SqFM)eKY{}8-o#q#)$K=bMFY`gYQPk#ev=@ql{qa+O5N{j|svPXOufMmK+D|z!l^{UZ6t?IMlQXY(BlZa#t%i zv+F1+vJ59VCG(`k>rS#OmHBTe)890qikMyLXV;v>u%P2>X9v3fHlYFzamqtCepwJt zR8RqhOxf!dW)fRH&=2w)W;EzT(PasG7DdDKu!K_nR>)wuoIz6fOAOM1H4=a}Wl;gG zH}#X}Q%Z3qaDI|fZY(^inxcjXTqqD{4X7KaPVUX@N}H*`y!FtXcOKem%a?7H*{LZ; z*o_i1$@#YVr=Pw!JKtauCH9{GU+9j^GNy-qK}pn-uPLAojmWOt%n83ggYL@vPF}Bc ze~W!yqx;?)R`M$~@>nSBPwdTN@NGzXxU8}5ELQU(7J~CHn6Uu&L!MGYR2#Ec4X8{D zer4VYASRmyHo>g2`*gEws%MVftF!261E+*lwzhjEnW|g*(CHSPx?u_v=aSK)Ox*Ut z#GN=3>1b7!*iPL*W^AH=Ty+k?v7-FNe?ecyc;B=%>9epUnx z`SZgktngm7S(#Bb%f%Pr^Do+TSBr~;=|l6CngWJYHR-UU)d45+$iz(z>vRVtf0 z<5?cmODakL%1D^MWKItr<^gwONL$3!Y$-O^B~y8NuPn zPf9!;kVGgUoU$(#EvwGYOI4@tmnu(@e|RPT0SYUdNd`(>Bawn4E)qf&5~mcB%?$f7 z3~V3x!^rW&g&<@L_)VcF7Zm~2g-;8?1_vxLgm7h4^7pt23g^e#!!5+Q%@1|RWhl;_ z{r>lM142wd1j@xZ6C?sR<`f$s0++tc&59K?GJ(tYCK9e}s}XPp;50xM zNNx!WoU$>Nl}rdiP03uLu%$`m1ihSKH!06DgyDa^tXN-unZ5Z7vlHr?D$`#a#X1G1Sqnvtt61!Ckz95q{z zxK+m+&!SftZ(3r4z9!=$G%dmIVf;qirD4QfzQYpi0lLA`{V*6sVJEO`3T$-;>=W!# zJM<%lkRT!o$1pVt_#@AUb}I6Guv6#YFkl-|Q%fkw%PDb|;5tXZFY``Usye53LJ0Xi z?%0ZW|ISP2=H_QNb&f%XU)kEx(c0S4^{|*Xd-27ypPrrXY`XsLzxO3>OZ4HPGf8Uz zpCyQJnENb&-~{{@$y@^P-CDx6s1<<2;P7D{a^b-a^!UVuNVMb56dB=dKI3fx9rtd7*rh)?B6ctK4Vjrn&{75A+NPi%n~+y8+Nqf?u2=e3z3${<|D^ z4$O$m?#tu{fUAyF-3HZ>OgVOB?z80%%(oz3HqR13d-_VNt4n=VXu=HF``1<9(rRn~ z1$)%ecW0h?D$qnT_c$?g+z{>~Y54Ygp8`7I(U)(ZXCF#|1Bk@JeWyRn9(d2x2{J$v`vsqG7jU5_rfDgUESK)kGouu3DSJdm%`4Z;&9=*TO!>;ys~UPKH8-iH$I z-hxb*Bl$oz3a*6_EQgm@I~h&su<{w)i(gE|lbO&Bznuqj_X{6N!10QK6y+mrg@EA> z$mMzr3s4L&Oi@}uw-^zHy%r#u^>jJ_6e7DFj;(2UR&=|eyz7u&(2%|cq8wpCT!hao zp7pBfL^i(AFZhAtp#Mx1eozf03XtT&@1;7xxO2kccW|tP+ufavdnz1JSHPW{Kc&46 zx^j`um(@9FF|w~KRW@gy+l$}KM&%}Uhs}lGpvxwOlgO!j5V|Zv5Kx)m$3A^8)dLg% z<@CK;PoA~$9!kuR1n9lI_NW8C&Pw8qWWDNmlN&fVsrL#wmt?qTIA{b<0FA&03eZT2 zmv{#fS1L;?^F2Am&SK&qn6L#g43aW*N-#aGe@ZOFO3d^rPh7Qc-&IlaZr-=bP0Kcu zTU7ZHn;zS-V@&z{YioSAm;&a2fAEu;xG%YcJhpJw>Qra#GWG?YC-M{MP}v(LVzqE| zUP&JBhO;sttw#KoZCYsOY5Uem{(CrZ*PvPgr$Ke`eJQth>UrrZ(Ks;y=wE7_=@{2! zVjKxb0tGfn8lXHagrVIcs=PQ%o3tA~;Sg735L_TY4I`8thWTTkqvTt`8HXDZ)jOt`C2m zN4GL!Ttshi2{W>Aza)X1um%)7hU{D=#<&-pJDs&M=x{xAdm9ET!cXGT@C?X<&R438 z*e1TJ##kIs&5!h0` z3DIybZ4pV>24A*-@3#SOQY&mrOcmoi`zeKh0-P|}Rg@5f8gz893B6>%zK5ShKh5!^ z1RFwI)Y1~_MqXVBTyt${Z9$$Z$L`JYBK(CY4{aSi)*!X}fnbPwIZ#kN1#a?EHZ|eo zt58C;?+foqBpEP`=LwdOpY&>Xat_*&fu7&J|m58VwJbb@(jW#G^qxI>MtD{JUCCe7Wt*C-~#O zjMunFiLhO46|Xq+b%dXZhof)@SkhXZ#V@g@icrEkDYy16mk0XhL!x^5`N*nFTN1}_26x{4gUI919-$xaj3GeW^K>|Wgt$9g}KB|_*29@ME1$a zi=1f-77&wWTq~#S(;KMpg2{M{>}@P3jA@gJhX_ow$O<%p69AM`?0~UTfFuK|BbJ?o zZ-1Nn<7Y4$Hu_2hp(~E0 z#71x!f9bGqNKK!}rh+h59~tmYGI8B5cw&gVhe)3Qdr5J2zaKhQ2n{xQSMlYBq_{>4 zu4g0~`6Ev>bj5t3MB}aRaTVmdZoI`)T5CJoVsZFpD6XH$OsgYY z)7cxjoh?OHT4B(z4ZNA#88Tu0S_>KRW<|oQDCDCT(|+)#a^NVACrlOS z`=yhORBME~xiyl^hi$xw(`1x^Z9&9EoP*-2b^C%J51Rm446+R2&wruzOZ8{HjDU9N z=%rpnsSmu9S24A0tF8>x1?x&zwP&L&&QxIYtnJw{+bq4Xw!>1o<664C(wSQ;Y0cEq zot0=GbW=_p&XE~hT#a$^5~QqU6eazU+lRb4iv=+ayM+o_m<4kH`DE0K84`wDVuWXL zoSr&(5UI-(53|>Ij!cYRaB4yN;3@duL}B#wg`x%P&@K#;FFY4&EP3XEW6{BT3){=U zUG;+PWz0gO(`GAp+W|9|JZx8Y)MNsv;BmcnIyRjndaiz@sxp`gn0jYC3u3 zOs7m%!K!{|E}c}gMB*oqIk#Ul+fs|jbwhMVO<%CRv9aaCt70FTJ{d zV_&U1R&{8HKHL)a4~1K*%kJ3JUsiz7jykzFj6$h2;c+MJm9GDBnrD>#EZgYpxl|mgpPPyO9N#uRh4%Ix{3;NUhGPH1tm5;SESl@Lj1g$2IuzWMm!f!hWS$F-+u?UvTtTBqvJ*zdO1 zEwxi!jW;%Sbv4qbZ={FDF3!_4=u(Hop*@^3+p58q*@#7^UX%!u(wW8Sl?eTWFbTZ4 zMyQHL#3DIiaEJw<>ha6Q@^U7$#v1C&>&gLuiXg44)a!ApG@+~`$|+e(lK`jyj?Odp zRl2}xvyi17v}uuPAWY-elThJxE} zx#cl{AdBhDWTBH)6PM9J!W)(4L$SBRRDp=5V2ms+nh{4t802P%G&23S(lffb;um;q zxVJ2540?-68l!8qDvil1Oj%DjvlM%yI2<{2;E*JLq@RQ|?TpLzGTq+yPL%1<08!Ta z#$t#9+a+&T9p5g>8vnlD+FPxZAceSdr@~KDek?SHucg^ zu(mcBsH-AKeEFp7)QY7x+SkuT*QNOVoLp-T*XHg~^kfnb#~K zo~ZWj!g?-${+;)mfU4nu%7ky5nE^Qw>{vEFJ3wE+G`U_@5gwP`0_WgZs_q$zS2@Sk;w0A6e;OXTR?ma>&Fk9x_nSe19`bt zGu0T;!wMr4q)86NG)oNJMwV4p67$$a$sMBu2L8e}j?N^#d$$b?Zbd)c%JQg<(ihk|I8?YXz;wwewY6@^;Xq}Tv&voT69@m@>6@7v3}(>5$!!o=!$$#C6-=;O zk)g;fd-&urCxE!=lu&j9_iL_hE#%?&OtNCrATc+n!|4MGot!`-&>L?|D8HJZAf|{s zqrdWgkCYb)Y=^fH914K~!b_w=&je^^aBRVq19TS9J5OACeYmC~b`mACNL^mHID@3+%|Hbk6#r=a9#g&__gR%DAu4s1O_HVO1 z>p)9v(A<6H=xbwFba!7qevPu^4tLboBtzojzx|s#9LCrstS$Kv$QCCJ@S23V46z_L z6nyg5jSQs#E&yl+COxw3TXj%|%9cNX^ac@Kpz$$PSxkLsI#E(t zLC#!NTeOGlA+i%F2Fs0goNzkb+VRE~VU7l;SM)%jW6S%c8Zm?<69-$VEB<#yELKt3 z+`M#Z_6kF`0~|@0oL$hv8zI6{`zI1O9i1cP#U0Ou`A37f*qa9jG(jM;6Af~@Wq~At*8F~ z4p!5j;@IOAq~l|(a;p4T9tjAP%0%En;x*ueP>{WkR(KQni#DlXz z9jz*;Y2CvQ@7?<_Zh*7k61IqA!0Q@>QquEai6Q7jktZAm?0$q-a4toFG4wba1d4%O z&QAh^bB*F4Mj0hwJq|#4Wu-|=bz@Vn?E3G0za1aD zeqgL&rlobdp<%jp{JMdG>sa24_{)ob?Z0lcb*8ClrnPl@V}Jj(W24vgtK%)jpsU4S zqk3;d*p;}TqC_w6tY5s`2tr)GK`Khw*Xbdy9%lXb9Z9DBdFq+u-V-|j*i#V zj7M8%vimGeB{dx#H6=}!zU&$2#8;sIbWeN*DiAvs8LN+uMLMH-NP4aB_U6@hDc54z z|K<(Wg%2uUW~QQGeT1B7G|w(R&r0NaT$DIaj(RO%;|REAOwR~pH(}bgS;i{s_BILdW1K8RtCT&0Wq`7BQ^#Y_-s&(i7-Il(_ z>irXYHy_O}@#Y^}Z~h&%6;Z=Y?|IKdh3;IGYbF^<6?U7z=k!8e!VTZ0Fa$X%2$VrV zQ9VlBa59D-*M1<2P4jo$#hzz&H8nc04%_0qVxiOf95BDGyB-gvn(v;U<4RDuKiDbZR-AT z5##JF7k2o*f)lahUv$!tI?@xA$0Ng#oY;sfG`6S+n4W|Q6xji-uzS0C>w#EX&O!IFVcfPOz2y&!c zBkHiYJbq{bP?<6QRS0C>p()?Ba-alm;JkX(KsElna)=sZ&K%q>l_96cW>3=;KJWq$ zulfuiC1gydXn{!KB$f>%Qcw>L+3+BATC}8^NfBjn`j+XM7A>oW%dkn{ctaro2$hwC zX0t!MXY4Bs1;L-f~dFsP>Vb+&R8p($PPAO|rIBhQ33=phOn652kgX zF@ypER9@F{6NpooGXGbVq3hS zA+D5qYIA@8``p^^#OjpW4E>iTH(Z)Ty_;I-UFgfjbyW)@7PE;RrNueuQ^`h1b1}hH zPwpiA&z<;OfuT8)8N!<_6aGWcgI%Kl1{%TW1(Z7x1Z%(=fRqKVM#0h)0>TCaL?&a# zSzYBpfct>-7ZmPNS@fmbV`hs^XEis?By{F2z1xtg#=fKRX{DWwzbm)U;V8(vR{8R5 z-(2tq-^*`GHewW+bMn(pEXjA;!2HxntyITW%tj3rZsnoEjM@0D$W7#p!BTUvuAU2^ z59!H}N}{IG%F9!^;rg*28}cwr*2L)b*N=DG(2K@k>&ckT7w+AXooBb_WpDZC&rdlD zY_@y{gpoRzL7XcKWD$Hhkc4*V+EJNz(YKk$qg*`Th6)9O^AmFd-3GlNuA zJVks|c3Jp%#_*Uk9#{T^jlR5J)J#uAfSe5A1~TkdjLAxS1NjDY(ou*7O-zl@1&xFO zm1V9A%Cj~GoFyM49Nd0lXx4md9`cS-l(w&?^tl2jsqw%2%u$id?Q@362hscVcf};9 zcdhWXgtfA;Kr)$Y7<~A!YjeQ%JE6;>n&^UHF<90t1fR6J5YTgF#05+SgJ~M&M-96X z9WogJN$H|5Fq(0+{%79+5dcrR5TQ;Y!Lm}f(^_k*U6*38k5i@t#JP-Ie2rC-IG}y& z4;j+?lohDj5*~dK)J=_$(quL@4}WmDu4s8TG^^G_WGvcN(`+fQ)pvK-*LRD_uXO_R zz-jol##2H9a!{e#A^pUH`66CsCeQTRjiS+veAj^28Vu%XfP{HO&1?V^2{08+-eI~S zTj+eIyT(#lo~Cad8)?lHGYuq64Rh<%;)W)8J=EwY%0KCh;FG9hYBC@sA)5^{X#+45 zAm*UXz-*<>4@k2a;!dxZr*S~_bJ#1gexn6Adx{hKyiU}2HaA%s!FNprkFXgEpwVy? z2vlpV<9UY!J5?1|cb*}@wk>T(l_Td7^XmiwNPjbd7a<>va?3gXlBWv8JXkg5v~Hi4 z@KA`1<0v^Nn4M;gC81ZLHec(C`7X~~oz1bd+Rlf21*z+8N7Xec?96*iGRe zlug2Zvl^`yERE4*HJWHi;G^J(C^F7ThyhUn-nJ2_l4X8WWdw2&OF`cZuDOF^M;KaR?)hZ=xeHzw!Be&K|n{9hOA0Dp57G-tD^z?j%30$zi`nvgM<;%C;s$8K-7I;K5^ei8|iV?~o3;Xk__!0CGXcu6& z)M5ro7Q9Xf@*z`RlJv7?g9-akKVDH$Rz})ZMWiB97Agx-{309hURCQNWDgJ4q~cdt zDrjBECQ6I$8qq0tiZN7?TwNn4d8A)|)wDg|3Z-lMN`3#4UAH7I?9?xv)OYOd`o~o& zSLa22x98S+pmW{ccTuPE+Mf)G{fWed3Bw=34PXNU(pU#cYN84aV0dr|85jY5QpP=B z2jFHQJgMjx#@*nEc_<|`ByW8AWu#wCe&Gvj`_F%_d@jlypZgp(whsO?0hpCs2wNMX zC52Q=2_@^LAzDs+p_=9na3P$G7L?6Ka=%O>8Q29%_ovXak!rly?Xs*UXxlw@kINz3 zWE&b0IFq+dc&m614qvWNnFvBQr5J)}g`a`c30|>6xDqQb zA#Vgn6$|KN%m>d8kO3iJWaJRMd%&%THOdav-?+I|lCj#D@K?szTxSeU3j zSKZ9+T3lRIo?~Hp#7q8!^~bsk@b?mq$U-%afT5R>eU6<9k(ovylK)py31h>Pjca;t#9enyOi$eK>Z_18OxTNj{5MheI9KRPbmMMt9n!7_Cnazz z)FH%l1l@kBy@Wh=Jf#!aaYS-a#853kK+TA)Gzg5qA5+nid?4htYiOGP$R~gF#3yMK z7z#`ByJHYE5^w`S7(krnPSD#9UsgGAle5Bn;-V~Oqv_k&LAwjQA=?TrAxH-uD52~$ zx+P@t7=SaB&1M-D*PyS@<>ZQH*}Sp7j>0cHwr|^lzBi+T{e8WO&bF3VQ{8O+Y)!Z# zSn4e(FDyr?@%-$3h)bUbLY)D|s%f3HsX(|?2v+aczyXaxlwnGFwNn{PDKIRyC>V?i zN9JHy9!)1X+XVT7pwFG%MS(z3QQ+Rghu_srF9Pfa^;vmR`%^c(_l@2Bu_<}Dzp*Kq zZ0hc&$HxA{NwHk}_}?#&X&3x9b)ou~9es_BeV5ZiAAJoTSNsYxgQxrvHF@@9PdiZQ z6llhL#HMux5LkL~BnOesL2LNr7?T6woeA3DmSjpW)Di{4uCWELnzBQYlYmLGyNaoFU^O<)yTOI@$B9 z!)YB2{xe8+RYe0<4ELvam;+Hmc!8~$Hp+4$32TH-pm5ZTHvk>v!0w|6mGDr49?csF z@J$f|y>UDTp|u=u4tM*h{0v{`aOC1-5G-M3420_+DU4twBk=Lw40Iq5cLeG)!W}f^ zV;VvbL(UktN9au?__&!iW{7PXGyEXBqZvd~>rn8)dU811)rN_zv@;Ep;&U0}`;r=! zLmXz!z{n*@V?xZBYJL&+Cz@zGh97^tpGsEHxxknYi$8~E z5)-0{T5LZ?rP@%8ik|?AXNz-ya!BGvLS~nj*<)ETHN;ERN1(N3;IU;OuW>Tq^Yi2y zppz2lh{J7w%L)c#&ACwL+%CJw!nrEAAv;@ko6p^A95SH7pY806qJV&4MZ9y2@t`VsyGJ9-RP(ZtW|xWzPQi@ z&B_9^ia9AsiR#p%{4ikWR3gzXBHM!aCwVyebTcqk#QwrGK}@4J^&H>b+PXU)pKEKI zi)%zM)2wLRspr}Dy&WBU+ttUUKmTExKOUCXOPd#(NMRQ1U~Xs*nWLxWI@jpWI^MI7 z6C-)+^xJF?Jdieu$MKy6oq?1@b-$332=qh7eo5~GJy!WmzRFgbCayIA<9Q)+_S`~l z!kLAlGDDm-LjAnbT}%k?STG2OEPH$? zmup{8-XIkdFo_5<#@Oo$8rtzdBdIycx3yp+Zbb2lWlVDq#cE+K!9fikfmCcuRb?krC$U$?K+$Kv)BtBNu>X&e-_Px~qUXv6n!*j8O^U}v!&1d0 zzrVbUS*oj*w;bgTzh7CZVx>)DOC;JB1tQ4*9jDEXL`a(xa|qy2*NDI3HFn7#hdCmB zc9Hxf1e=8_0*44Di=e%c>Y*ZoiGqgv0no8nP(&O#V;1;o;jSDHhrzUBVSuk18=~Cc zfJDjnxH!DFnpJ6m`}1~Hah+C8b#i~}JiHyn*I&=#_<8Tymsza-q`%!Nh=>Cp)1SII~j`w%;0~p^PX{+#Y zpz>kA801CjHflz2CQTax!SLM#Gm3&T)PhVl|KC4D8Q!+@p+>3di96>JO8MxpFH{<+ z`=Xb2MOSM>gvZoY5o0ytDNPG}hl=)8q1Bv^^oD!eVLaB>tlSkE;_3X4&^qC_^Pz=f zy~x}-sdL7FosRDnl}@cf5AupGP8@1b<6~#xy4ndkdzg$!HaxhYw^LvHx1$d7~tif$b!wL;EO*h&mwNQy0?6awsNN--9YeS|dW zU9g&4DffkZekcS+nJ-1Gh`L~))}A{fEdwAB>g@qFmoT&}w(rWBWIUcs#>XE*3F7$^ zGY75`Up+EM??mItvBxGie^b6>dvuOmxX_;mVb@uq*XEH{3urVS86fOTz{pV97pjyF zuwKky4nyn`soheZke6GQ6Ec{{FC=k$hIAlQhb4aw?7En*NfiI9F|wh(qkl%ade6SA zS~q-0d02dDlX5CHT@~*i?A&wtWphoJ4g6>ld?}^#I?w`mIK9>{ zn+TdCu9E8+wy_Gyq5PNnnlN&L3xR~n>2Ssoi!(wwM)r%gQC6A6??lEwowk^-Iu8|x zy(iOxR^D}XO^0}M79J>$V?`7IRX#xjRCs_{!~uso28Vz_G9UpHYeJ;~d4Uq0ghJpp zmklmPxEDP}IGs?T-NQ3FP_(Qe6}Gvb5-YRitpiB1+`tMcdLvq$m3Dij^GCpmjsps< zLIW_47{;*);~+eHPoj&;jbpD;mmh=);nziR8`xGYqg0(;K%E=j+=p8GtZnLJ!e;fU z8)9u!AOCVwALd!v(1*n@Q~UX>-^+*gt|VM=`f@Qx+RtlS)=)GP(JmDBu^2r}FiR-W z1DlD;R|DjEER9?+nNrDZh)xTXk&G-O>}v9S2K*((__iX4mphJarea{7Q7D$2#`xi> zo+{)@rYMRVovFD~6d%!EJhWv?JQ?pBX=`lnn;MBH+pcWu?P&uIp~OMSST4 zANasWvv&@(PujD0^-mnIe;9Apb~ZJ2EQ@u>@?8L2T6X~ffoQ^y;^;f7moHeWn_+w{fen{Sf$PS&&-ltYG= zn#sNLO>A>xx0!v^oB)`F##}8Nfqn8WP_IfjlmMb3%Z8m(m}@nHy%hmQ2K!VRA)n3= zNRC$6KHxB%1jfb1`@m}09QIYWgrtH3OprS()W1&S3A~X$hiO0&%}Teq?KL@l@yL`mRJhK2ubJPeeOPTJxq7Bdx6?{&+ALC%=Ov3}B72r3*!HI+0K2aA5 z*MFkExi)OQ9PM#ZeIE? zW4tchZ3mzoBb+&X0M{Z8fsVaq z%N`k>90Y>>>RKp2r1p##K&M8Y3xf9QHPEWnE99~1M5wyjTb!x@9Prahr$b-xV8cTM zF-VPh$)gBz5uCmuv>e%?c(rPQX%Pze7I}Ff>*Ox2TGHj0&+Obe!{o8jmd?%=luo*x zeKtD3vyxX$iVsgs4J+co=8mysr?)fNm+&U~e!_cC3QovMGvs(RX*IAGP>WT!i1>?@ z*{rBbgPsUfYZ4~6h)Qxc0!R`@1d<#w9iX~7_(frlL8w<&1j|#AJrC7lM`vajt5nyj znpM;6$p6JBMbRNOY?6}ssqmGG1*MeC#=Vyh;@{($EDc#& zf$pSY@;=r7o2fv=qRbO;7q!&4bWe-(bJGU{Uemj%?xyl=c5Y#NeM{doTbP?Z+`vAV zEX}FTL;33ZsoAlCvZg{~@}H7PYjz|r5bdg;I=F41oznF%S26+#$DSwnD6WI0g5Lz| zh0r1O9Y>ZEKy7AqAC97xow3yjq2Mbi%qMln;RvF_3qp7%FBN#fn&5|YZ(=lL%F?2w zg}^*@d;4|ITyyhW&(YUjJKEaY+j{i)#MA^EYS_`)aY4h>#BuhazSh=08XGJEjLm~} zs3M7r;ZRXBEjJwjKCnv+5Dva{b~@p|5dl?)h^wjqOC@4^%zSvAa6E@FKJH_u^P%k= z0-|D^e8|ulhx4L-{efY-*LC^sJ(scBo>*HVuPp29_elPRJhrgc+0>d}m^V>S-x{3S zyKg!gZ*B57)Rj!Qa;pcM?37Eq{;(VW1s~;^(FD@h7yGKjw4;C+0rc zBC7dng+9$Itzkj2lMjGq2+kS-;6`^h39T~W&}cQ${UP8B3ura5thA))Hmxow+J33( zlUm`6FRH&O{^XN)e|^^nt#(n&vVQP_uiyRTlTXfEJv=8FZlMXBI#v4~( zjc*5Cwjt-r0=oEUJyD!q6iVTy;}vcYZ(_PdsBvHvT;~T!RlwK?1GC(<)hGCJ0%a}~ zKcwC^s%D>(3p|9NC1k5&oQYJLX=ZFEdw+j>OAlV|9DDcy+u^C(rViU4xZk{I ztf{rP^Y+fZw#KnN=KJ|L2QbcTv?Gd;{ECPeXMQe^bb<|N<3y&P9vTL*20~rwrCAKM zLmER<*)mtQ!BoVX=%x*lbN0?<`iz#bgBzFI<<^F^cDXCM#9lJ8c|O%5cc3lYozWr} zXF7>KiaBB{w4D;+*zgH6BorDg0naU>v=}50b+3UmfdRCIDSF|;RyH0CO~k$o06< zXMogC$OTeqpoIQ&V`WQiZA)e2L|fZL#_J07g0H5ZSj3VOxnbBfd_yG160w(**jK;7 zqP<5dDvtEJ)F+Hh!cP?X%yC2m09_W$U!yXt*bC9>CI^G9;Bm-k*aL)7Sozu8&nv(E z`Ol-`LFGT0W9fG0yqg_+@;gsG!Slmk65e1puzy1S_$~O(lT@deoj6I^;G{S0#XV@9 zviM#^RWWoOhb~T_QNZ0&jSgtNP`g-0%{ycrVc~$j#KIL7IkK(LiPY`nac37g3e{Gl zK-5~O=MfEvD`Cdw9MtWRFD% zCn~uY;iwLb9me`rdKT7cZ|Wg+M*YuS(LM9*O!vc+QvB3Y^aBc`-T?q$)6ywW`i%7s z)XXhZC{Ui$SDbv2)`Vd#1TijF!K2|!WTO_X1*Z_Z}I!{?bDnIg3KU=KiR0<oSA{;NmXPmFs$b8u$n;Ai$2XG~je-Ld1=Ed)=;(tJvIjD4NGi1Ena zIp|M7JSPAP5LK{b7KWMqfz6vYKc+ryrfV<(7BmaC(N?flN>a?3vh5hg2kUHE8s1xW-??=qMl4FT*Cn&=iiJSnNpfWT8iUf{r;`I_F zVce684Hl$OX>O&b(v^c^yhg#z+|)e~_82$F0IYQTaGVJh=5vS0jv)+`n~pfW2dH1( z&?WKsB||_#{rm9D%rGl{vn7#edGmEoVWH>sB+A9?MMZ<;1*3aMFGwEmXs&4PNEUj$ zp28%rH$zZCPuRBUZq0-V|@F&{ykF{s^ zw@?Y*E-eZD=b9I^D~Qf*pmU+1b|Hg;3My-PSG2o0IEwqAkak5i@koW{L`BkgI>ik1 z(})TRg18(l0}){AJlNHBuoLLqABPeF_NdYuNQ78@vUN}AhdTGPPT)=Ngct9TuKqdU z5%wtiEvR;WW64FAqAWXdJ`93~d9<-)E+tt!>T(t|cXc)2b5BY6Yp<1;-1A6Rb5CC1 zx1n3n&lGz=nN)w+DK0p;>>#Y%Ww7Ub&|*pipk2Y-;Dt~l_mlPwv&P`2fC{zK2a;l?fsz?_c6@EsXKnq_GKX_}$;hFY^`G)qH!#k889NsaX z`kh_1W2~~izH)4ba*B)_dacq$5Dr4us^)xB>sf~LuX0Mz1xR`Du~p|^Rj<}6RvE>l z`JN%-WS)D`sA*D5&#|5NX|LJGQvCF?iHWbLUSn{m#e;`8xqKjm94cIsHwb%##s|PC z$zXhL9SPh(Jw2NEe-1bblwYur@(BAZn%7VMb`tUk-xI|H`7k6z2~ekvw>9Dp#$)({ z$iG~i1&N!GM%y8%0x~ca5M^tB!iMlB5&gPil0>bMFNXM7KYRs&LMn+v?|!EtF$VVUYRBj(QYtg32Abb^hq+U_(xaj zP%A$RJrSXd3R^|MU+M)s$(@r;JSWzqHW>*8n)Gg_+E#$nX$D81bL$F_c)cONl+V&;Xt%0;gB{=Cw>{M@4S3zc7gRcvIx zM*J5&9dV8cy1p9u2SUIP?J6&K;3wN?@NyWw>WB1j1s2*YYLgH&2oRDGS8c&3zuY@K z)Y~+QWoRxf^OyDw4fe$*ay+hv62HInt7FOj@z%zUj_QhFRo`fT|9D$tPYVJPRkW{8 zpMY=upT%;l09EIsLkUeokZ>lkTGWJ0*u|F(9|rOrJ`K`30x=(S4xh~4{pix=e@Hwk z-tvbdPrmWw8@R|U{D;`bz5p~i$2enuQmz#tr8r;7tk_B+NfbB9&SFBYE2|*8fHnin z5<9a~k)l|N`+6XkRsvrRQiUFWgUoJFZj>ARc_k%z`1ud2+EeMP@>N#w-|-$sjS*0N ztlvSuGz-CmpU;ucPQ@EQyo2cQI8zriq;cqQYi>fI$tfum|FUgvW;QavbzFMtRD25^ zLm{H+tGNQSDMSWSZGAP$kza~xcu>l-S_leQeV4DCDCb1A>>%&Q0qYbBAsMVdaG$nU zSI^IftL!sCO*NvN5V(VD!WHpTThP`alWC}xN=(ySaPQxt?uJ=dS$BiFY_U0ffFXkj ze>MC3{CrY23Mojq$K19^WL9}jdTM;@#FkSW`-DZ-Y*?~jjbF8(>Kp00asc*gz@vx# zDsC%N-co)JGuGl${!Uix6~VtMeX(bHV$OET-9fY+b^ z3dV;Bn_9z!imNxcPtP2m*ByHMHA>;o=<((|sY3!{I8MKHTzO7+XyWbH@ExMijR%j$ zli&$KSP5s7coJ_&JdM|sA4QHHupgAiAqI3NjuHqo1<)~CVAkH_zZ0+S`4oZ7MJOm1T zT10qKQA()OE_MTWCF+`0f^%M=@l4hPcG+B65v%l-WiUU>XjiK9f#{Ul|L z+n5=-pJxKqb5V?=v9jRC(3O3CR}QI<=d5)8OuE830+ASB1T>a~(nK-Wh%=Qf44Bu! zhn|H*Z_4h$Pqiq{C|}`T1o0o1U{KIGTmBx%H>{NEGterqQQ)5O!2PoPCRYU7C^@QU z*cg4+)%dO;eHZEJ6VTJ&fF8&@##>2EK{YRYhbS$=6@sFmxMd2FPB~&~lP+DhE<4?l zi;AleCY_-$w_n0p#8HtVuxcC+UHj0EE9kW4SQbPyL06!?-V(|MEx>X!NxB8WZnGc5p_h=^$pOX) ziXKwKy&(U7cxG%iVJn^l+iZ502y8tZi38~`LRZQ~`X*UtTKpe;6}*a|XstkHY_$wg zzc0t(LhON#F|rF0BcifEF}1G&)pP~Hb3M=l(;747{rU+phOS)6_trl0lgffJUmisy zs+;^-{aqVt@X~JeJ^Df|Gbq1l?`j_zxn<;9XMG@+gx}Fu>-^~(teGMe;vW3r#0jjl zU+dLeEChvT5@sJWpcg2*h=GzOP%;WbNR%$MGBcE(Bd{D$YsMhz4}e<%oKfpgHDmPbmvBOa z0vH3p2_Qm_uMD&xLIC|lq;LlPFi?T8Y0x4$2z_gNi8Y2e8p`{@@Hbw(%xb3u$Krz4 zWTG(+y3~hRqw+vSvVx5^UgYc@1OZr^IH^B=qF_&NxO-1C2r|!}8TTu{2P$GvV>yAnvf3$UL5G0 zWN&8+lGYzxVhT=A66t_VX9+oy`!yxZdK7@H$(JUAFA;3mb#{=_jU~$Z_ z)itja&~JzdysN^v4+Ds+`%w zU((#<|5W_tzsDsymRVV~U5%X+T?e`@sp+a=ZDD`x*gf%06eHc10AS-9%n$7}P4a<`jtXm_$gHz{Lm+ z2u&NT{p!Z}DE#ODA9-&AA60e5kKg6ZO!j25P4>)W5)w!tBq0feKqi1;2|@r_f(s-B z1VlgQ?JgtzD43{J!Vj_h#NCVN-wo z{1-HtmpAX-d+xdSo_qGQ97x!9d*jhU3#jwvf<{&X&CX~qPnviugYG2l*WZZ zj@-&$AZ%M&*K_> z3pTs=7g*bGfM3sI!H_?bs+LlfV3@h!+4a#xxPmFGCTkQ zmNzZe%|5@m``tNB3wHD#-4y!6$nr&s@-@#L_t>KOr~JqKw6*K3zdZa|-li(0le7B1 z0(@%%q+~zhEm8||vf=GVPI2fx5nWp6U?Cvjh}ec3^eI?e5}vR4U~&P8{P2Cv1&?Gs zVbJR9_dt6n3H>P-q>OA3X;d@BCW0FhM&bf)X(XCELW&e?G>byFJw5bGo)Bu9yy90= zd4l+zTmNyO_fW| z8uqiNhka%Kv!bbjSjp{EFTY%UY}?j%%~1}SR4;0b!0bdRbs}pHXDlb9YBEbkz=02S z7)vo@H6A#NVa7^j8pYiO*rG0hS$Gp&nzu&Ra4Z$bH{hB{p9JfbEn;P z()`oSUVeJo(h{1XL=8jVwMR9KN$dT8ZS7@egx6kaZAoM#_S&<@Gkejrd%3@&sc>A^ zU^dGz?njZdVn zoJMlO?%gCO@CW#1CuQ?GVv0#d_zh$PqLIozN9C$>03qO1eg^=COah94;8=mdQJpTv z0wj?_5~?tu#6fty1St52wEhe|u?`k9|IjM)4PFK4nj#?cpn`PcOx_6i6ue;IckkxL zN!jLeTAqPk5tT@Mq8573qqVPpCX^@eX@LC%iba=8AYF zWWqIFQ)^H^HTmQYSwfz-mOhFzrEgsD6e$~@0(pSX#&%u=KZ-T**|-xo$Sq(S|4bwS z4)^_Ts*~od%XVYU#TVCHa(T_=myEc0+lY%Vsk!*_5tm;);u7#VU929qyiUAFK3%q- z0agLbBu8v#q^K>2)|Ol}qHNW+yT)hyvTnO?0 z*oe*9G6M7K+uFX+ej(O5h4uBFYG`0{YO$H*MO7(Rv0xi#th;>Vv+tkSbmE0~j(qEz z8I2#b?QG*ksH3s7?E^X^p(mC~PmKC`YMz{?zbFG=v(uJGbL@N<@{XnZwUzvRRr$?M zt-Xa$E$3NMUc}ys#%(QHKPj(|X_Cz9P9r1??0syGgk_q$=)NL$jDz&m$7o?olxD;{ zLESmWWqzb4mG&XoO&YL@=ja%YJP6RF5+swfl%tP^)|Gh{qN=WEln1*pPtlUdGf`jb zs=7FYEm|Cl04Z!n$Cj+gXt9g{mIm6_>nHKS)- zJa78=risVYj+wE&eaO^6LwvD+|FF8IMGbrLQyS56rFJ9kTa*wyBIa& z=kldBa|`)KU*l5qPskfy+g@m%p97#(ooU$g5?EpAAZvsqIOqCkbSnPgC(=*nAJs0* zf>?V*NZ3|vMCJ2{wU?zzSr8uv#WRe2I08O*D%+^tXg!-#&;S2kTLMEYg%9f~mGp#| z10V;!eoFqt+Hr(trZR7!BtRCC43+;PV*H1GYgz5s8Iynb!_eyEch4L%LWIUmZforM z^*mTBh_|u)e}r2s#0{sfFZ@EZ0PLiOli(@D=QTs9y0;h*o+4%{u+tV7!7~D!xEBFh|hPe6^gTmjLI1 zjC!7Ce#+lBFW{->-}z~~XY%!Y{mhx>Mdn4ycbhYc&7#=nWHu~RPR2>-7;sDFkwnxJ zdAO)RbU{2U$$;sIcy`0$7LdebWM+D52C0?C6V6)o^F>G#kJygboyY9queOO<1QQqME z!KpCe5>>Ok*cDaFM1su`C{$H5yg*Wb1MHHTj44$Us!Ol#m^-%BBdVs&A98+qC{(_> zvtjI*(G6qA#;xsY>s(!5on6;Cb8K2}-Lw;%y1Ul4wKTVlI|{#=TWH>oM}5w3;Q8ei z+cAE@ZonKojxiAbR}?A)qM(*RcsAzf)V^+p5SGb`NI!hheDf`BmLvz~Z|Bq9+q+Ga zD?go-5E=7P($jY6b|K)5tZ$hCzWtv`JuyS0v?fQ zb7=22<(D4E01s`u2t0^Du`Jo{p`EMePda}iHACQZ>|CkdRX#!l58hSq&{$HGb2=F* z$uB*Fx6fbQdHng42owxso^RzXo!~B#;3GE$HI)*Pgs%`#fJ8fiO+28QNjr52_YCV7+Kyo+^;V(1i1is&CyMkQOI4~tVG$1V;Z z=K`hES$*Gk@wzjw3$=3I8p7|^7x;+FyYaSgYy8$v zo;13_#&FxTNwX)<9v5nuI2!q)REpG5fl>W&ovv|RIsmT8#-WITnhRx@#pxL4gvk01 zq5&>ZKj(%8#QE2z`Vi;M_4Um>^b7vrcymfqP9ZPI$tf)SSM_KNL805=LMG|1t!i(ya94x_1 zx;CRT5;r;X%Ee);{nQ2ga*^tBz015dk4*-o>$zRyr2cq3t#y40!Vha86Z`b~4 zl>{5+3#ILl>puB0cuYDM!=!#a7Gw9 z@ADUnhYc&nk9m!CL4VK>57-yS4J|Gn>ijXUcm74!m?{I*0>1Lnr~IITtd#9)y{8y7 zV>I%@AY&igHi!=*c^AhzOm0YYMs*w9@Z16=Q8mj@`5{iFJd0b9#G$5y9ih>B+r9Z) zyxtxvDK7T=smxPxX)!82`GfvoQ6M)vy)dJYoMMUt#ern<84Bk>!-aAh4o0blNL57B z+&eidKR@fd^J~V8sY%OA&B#c_&uiL=Uj%07XJzHrH&j=S46aR2@nYqsq+e)-gyGD5 z9X!ujsQTcRmjDIOcj8xrxC`+=*{m@%ijV^i9_j!H`*5Ok1{TnIa7{-gO_rIRiE==Z zm>+Wg9XRGkEz8)WPHcdVlMHI(Cr=*Ft9t$;V!G=VHIs64=A?0D<0j4Qo=FU#j?J&N z;29OFi{RVxt8-{vZFt~Tr{KXD(!oxX%!M#W>r^9^rj9gq2ML%0UPUmF?NdW#z64k- zAnH2DkpqH;g?z1B#a;YORz=lZ(~)tLRRnUMc$61$=>2N?I&BNbjM{c;U;oTW(pt zX&bDXp6tZA%Qt1&S2y0cd~RG$Y+{)=Ym9f?qs|;9w^|Y9P80uytAZ)%6;~_;vW2TlE@UfXiFM+rPeH{>nB3mNvX=jq5OK zJwk}XyYp}O8a~oF6yJq^@Uxud?g+V_rFDolC9M7yPQ8mLvsPgQCvmlf@CyuA8q7sBZyY`dOqe)o1^=*73~K}(@HxF zuA@-M+J2Bq+-scAE9RSi;X{$Nr+Yp|GuV%e6=02=s^rEr$|pc~;mwGMDRNz=ioCRs zNZ#hiMuCkq9hKu46nSZ}s`@wHl#qXY2%Zm6Et3GaSuFqqXJTH@8>BSaXZhEnub#U| z-ScADowg4Q@iq)NlyT^QwnN+m_11d=a3O!? zN%Qo7KWo1HoBuq|uTq`zt|$NX48QEz-go!p-$U_!@3O>#H1Za1? z$OpZMf94x6zNjHP=9O1oF%$3$Fmas!^Kt$+VDXWz3U>~q(21*b z=kvG8Jc(6o^Aa>dI=jQI(b*jxq^QsC$ZT4RU!C1n^R)G?E_yx|zHZ%(bv8p!G#_!x z1UIxESW6&Cm^#otXDE-o6H34ZP(Yoi#ZE$OtnkezizOyp>6?h(uwf3fhX>*}28LO; z@6{ZJ>OsLwy_kL*=H2WK(4?2K_Eha*HU+K?VE-p8%|~jga-#unQ1655N$Gn9M<1J? zf9%l(liyfWcl_~)gawpX+@5d}|jAj(kej8*sY&%Pa(;Nnn!X(LX5;7bv6$c{@=kxkSx(0o#bZNApcIBfMkCzY5!l|q1ZLx zWGmg(%%VQzh*=DuodF!bn5-HxZ$5<~%;&Lf#M4POUd6zT1^v!M#N>_#GJtH`AXI)O z;)F6uh_tM*VUL(JFoj6Hs=Q*noYAzjD0B@t6$cGmbE*Xns0yeE z=*&amghO@)BGkw&$SK^h4dSGoswrFq%E<$N)7;WHXw1;w)L}a~~(lTyrL*4L-(vqTr9A8?pI`ruD#})_JR&H33 zCPWT4hd2whHn-|{!7#hwh!;d;fsg#d!2y&YsvEi%*y&5tpovGuMD0L|a@{`C$B>%F z%~4$!7kJ#085K|Xx_2Z3uNxQU@dURkt3I_eZ(-x?(ZNzzXX@Cz^kL=&rNPmOsOLF0 zwG-?~Q{Cuaeoriol zIAo*?-ki!~10V3$APpazk&{g^mIe9QfgEI$Ny+kN9XQ5P62BCHeSuWqD61KThxYL+ zC&mg9J8|WBy6Q1;6*WZBFltdKv90p6(0KJO?nX7zuQmQ6<@1H~RT+ z{Tt1Dq53imlV}NUnGODpeFijf^yrD^{+z6=9Q>H?(w|ZDkPVp#Tv|~eJx$A)nmMMk z6BDM+PS$}#2WI75xNqhJbA!GNjekoGVY!ZgTLkmUu^_qf7y;NS|1Xb*+`O%_j0ft* zBDj?s_*GMGzBMWeW?t90B*=fBGvQS8K19JbEeo2{3!-2y1k7sfV3$M7!we*pAz2(t ziFO%2gdLyjSMj<2pg&j?IMVpsQc#3wtUKaz=kQPz)YajDT|Bf`(+QVj?T{C2DZ(LJ zLgNt+iLi;nYpibc&+By^fN=PCotNdU7&K4lruE|unElL@UURwU^>Rf`Y4 z9`s4x9x(`?H=s%+(?hT*52cebb2D?jDF-1`NO)W7sfCfwl|dDNgJi37Ztp7@;kvH79KQ6+Gc$3F$Q&{YCM2nCLc zmIOF_dJb$+U>0Dd@$q_&b?Nna15 zy8SZPFR6aHngis;$9BgY0AuXbY~{F-fm{#Bn@)*HaA1ky<~L@~gD8;NCNfia^YVAz zJ?YkjyjZckXGcsfd1?Y6($x-DFqejktvOJ2UZGe5{vAnT>`5r`1ibZ>7>4^$o47_K zOgGQMEbx;?nODRI)2C18TW6UcU=sKW+`%NMe&R3>6VyCFM^DPDgV{>F>8VPiTY}t8 zHN=}hatFGmE#3R(vYX?w6Zli+$ef&Np3QyFC8lEkeZo>zyNp%X4AHbl(qzz_(w|ytV%_)OI8$FT+(vOTgZVv@Nk3 zcAMXfREt42SUYk#?a=+d#lqORoVF+Wh=Wh;H2yfK7cyaaajK+qH?yO%2g4VIBb)0Q z9Ok5dA`3If8H&zPk#6!RFyKH2PGUxGXoz|i_ctH%VeMueU;(5RMgiVnEUMK<9Ej^F z65E%7WRh;3T^OQU+|-6(MSh0Qo1Zs(`|n!Xj;@(lRFRjNm6~37N7a~CUjTN9H-q*q#BueO4$T9b_4qw-HiKql<_Ad@v47ICsXy!tCjGQNnoe0iI=; zZJdHp2G3kdmlb|iMWGz%(e_X(kW|7-g26xDIe+`+-bl?yI!x%>`) zxgY=Efy;IN!G8R|&YDL(RxexnAczJnTOEs6M5|Zu)tEPsDoRg43m6~5wGf2^vBKMO zLw>!)=^3WVPWOF8PZ?FSrGIINuECU{kfItEhK9|Hzm}bI!j>%yww!%-$2J~ke!Xqs z*`I6&pxd`^Z`)3;eQLegaCSVHoPXs8eQNR(HQSc?P zj)&p_Py~^M79z0{2$2y&un^us3X3{_3PiVPLScQcV||b&2eoX{RCJt;KvHN$u!lUX zOK-gK#7!3J?tcK7qkXFJmc-^ilmNgYh%3NcLS40*f*yc4V9L@_FT@35jUpnXfI5M& z*7xi7P%KaeBaABPOiDFiiZF)t$v-!HH=TInjY~IiX5s3A`?p)D0!-v&_&4}rCo<@d zajrldbF0G_9c_&g(Up>uPI)?c(^dX9#8r3^mWqx_VWnMp#T7TUTz|zCZG8E;i!Zux z$rke(lG`KWPGk!o#Bzo8%39aTEtIp>k2q zP+BrdqfORgJ@WcMM%+w(r5f}(=E)2AQZ;O~h@InxZ9qp5Yi%_Q(D0-Oo;7cS1p{UP zS8ms9=%I!w>OIhu;8N+pkB z#FZcL5L$@TcCNN`gi~8Hh*CW+&Cd8Rmn71P3A1PSJjUm`GU69^?A+P0Bqlw^{P8*b zvYjG5aZ`8q=D1V{&>Gh(RlWNHh6)lCu0{%YjEV8hSmG3Yr;h=U|7cv}?E+RXC{SFJ zst{R<1&u?BRB*5|gPey8Nq}+j?E(;s<1$^(w;q3dYeB)$>zbR_9sN9@TC%dcdsAY% zXlprYMjIm6Tb7PAQsXxFfek*}1q9qwyOJ@1uIwXN2@vh@9Yrtlg@BT`r56VV73;;V z&_O8{l$d}_Cr}DfQXO?l!|hFB=J~1Xo1529Ehr%1p4T{ANhlh*v<3NS+h!cq(x&i+ zPJ7|E{7*R%yPa%bV0Vm&$1nT^$>SFy7}j_1*H7)1-+b2m`2GzF&%Lh$&okj!yCLLF zM2ZZVgc%mdr~^+u>u@SjG2o!uF4#b5QxiY66rDyfI=P`Bo--`}!~;F0m%1X}pglB0 zE=3kC+L%E>;ryabGoQcLY5nfNztRnD8`iaL*finvO%sqBTA(KFRMDx%8_!^Iz>fFO zsAJd^=wm>3PK6Ew3E>aU-YWA<;r zSmihSU04Cp<6Zn9v`g@7x5f+SA3CZrtE4(~7(kDD7RBC`&-RmF}D{1C`G z4KvkR`NU%5i1tt#OFm=yhaW9l&QCGhnwt1M=A}VMTBT~R80S5hi@VfZq+xZ&rlmj{ zhZtZ12(F&U+v=Dw(PbVt_aN1gVW_ebq@;EmumF|NlWePcP?@&|d`@voNW-A(G!wwF zn3#G6E(v5fzI~-MN1kgG%tL5mmM=G7UH)T!7p7`nlLlVR()s7Vh6!`5rxdp7fkfin z0ev!-(@@}#^8Q17b=&z&mV(}bwIklLJ4k8Ke}J zfMqVlE_p|96F@qw~Dh$o@*S2;H%#vtE8lzWYYaVUVJiKnHZ zXnEXj{_yVIJ%9ZA>jgV@P}TA__^MTydmed&;Dc>=n#hB76#n3uYC(aS;;jcZxn@?C zTEeaE`-n9V@s8v-Up*%wFClGEy!j|^&KU$9d&=C)@7cICIS-Zaq6hdSt(g9O0pDjL=p|FbFiA~>;p^=gD*5n0oW1{M#)PpGXbi{g(1-vnlL!pUgJX! zbiUqKloSJS(#pK%*Hng{dvb;(#0As6iABDA@7+I3=_>N2r^FVN;(V2UpU)5K6&uV* zOG)?nvw!!JkJb_4YZ+iJhb|jBAIKD#Bj5{}Ow|&KI>5Y$NU&m9K{78j&){2sx$KFS zC*Ju!+S#>o7q!#-C-}T>7Lzf5&|8!6;Ef^WL@Itsa5x9^B}CTTK%K5~U@U_bUFhNbi_9U4shj`#!7qBMy)6hZ&grW;{5z=?Mr*XvX@?LRbX)0M$WSU z1D<@A4>0gCn2isGQasot#rc`Z@a;8tNglXqX&E5hC ze8%?Nafe!)>OF9)2dh-MOdFpW9`P}q6-r-9R;~jBT&kDyoNZE2| z*yO1F&p6+q{QL*1TVjt#zO|oJXjo8cJY?%UL|--DR;zrA!U5ZH$me%NJ>PxE=ewQH^NK?}AMnx< zdd-G)QIv!U-hPagW6C;Pl35)b)~NOZhIfF5ElIrfbzG-Zv5Qv-*52mDE*JuB8a zLnDXUHwqkk&1l#T`c4Ekz0xrMsBm#O^Aoi;t?{^6en>gABcv9LID{WK9TPilB?+gU z5Wz*y3cbG}*N5@p^kP@MyjIYlcIp7}t!OS===L-;Ju4jh<%nP+x<<2V!^C_474<&O zYRDB(iuM~5VNk8t=R_x7xk_9`RggH%)gkBD2|Km^E%Bfx(L!v`U8-j+fiN;`x<?N_GumnQvjYu^+LYu%EO0*+cA6_Dl8@dxkyFUSfY> zudz3QMB+p#<&WbB^{W0qaZltm_WZTc{`bGUl)tijZEW25KfMGgfM(RO*R^l_|6(ou zg}ui>Jh#!>otL^JJa|OI|K7*YnZ5iI3+Kc`KjJXYslG zSiXQS;wSQx_$t1ZZ{TO}v-lQ%KEH@x%CF?t@EiCo{5Jk0{uBOlem{RmuUv>wu+>C< zT#jq|zJ1SL<#gY}Q?#=)V1&eQiHSupCYw1dIKy z{b|Ez|4nV$_oz+#$L@okv!5O4p8c+KY;@0tmENW6f$%rbyUsrBHV7|vJNCG-D|cJ} z@uiW=w{dA6r~dvzU#Pz}Hu4Jln~ja;9`yij;aWe8TX=pCHra04?O)gr;~CT6$Zw~= z>09*>b&M~*z~6YiQD3XCRWGUodT|f^59rias$Kkn)_2p3d(gRhT(z~)?ht>`a4fLG zk5EHz+jrHw1OzU2H#(^V8vg&oKjP;Hhw0N8*36EATz)jd!e=A7{qd}WEk?xl$*^#( zXQ!bi;MqutdI7tbUCyp%*Rh+}57`~;$Lwe9UiJWcggwrF&7Nk@vfr`avp=%e*`FEn z*6NH|Sb?>N`iVaEuui=aFL@k4F2^<9cm9pEBtiP2d-Q3)XFo&tu=v&ezR%i!+u!$n z*Z!Nnx7(w82VWvMX~O?wE%y5Z%O2n3w|TAl&=1p-`WySxdLrCb|CaPu*gxT3u%Hk6 zZ{MGcb=Cv4iRj;V{iL-79uhtix()_3Uoa_9o*#uTd;niaeqiCt2e-?Vd~g1NT>}-y0$w=A!aml=5fJz? z_ba6JLl-x+MhW9P!k6;-MQv8pgYYj_57Yvu};sChGQQ@)^4 zFz`BpK=~Hh>oFg^JM_eb>2Sv*B_zCVNxP<&6Ji_e+)HpPfHPh}0epso1;O0xOv_(& zAiqiYtHSvzpkfN#$`h{-U}Kg~;Cb$rQ(Ax}o*!D=+#Gl`CC9tc1fMW^1Z@AL z#e^uzI09VpCk2Yj4$@A~n0QokQ!$kDsFH_lEIyCQ)8g^e)%)NgUZ05@t`WGViKc7q zfLo6hg0j(cr$jyJsi&0nbQvsqv!ntQuv3ask!1Dx!Beuw1o@KeDT6D5W?bBGl;tyq z$BAnF8Q1f4MQ}{^6uxXsuww9(PZPa?)YPJsM6@8TvYuW@uZ~%Wud~PeD%@~lNmk_9 zl2&w5F<4SlgY&IEV0Z}QjC@Ed+2l}*Z4o99Qf!7ZX{yPmbO1MBU500HslPH%>7*cs zS%xRd)ii1WI4R1M`u5t|_WDunHMQ+giy(@+G~p3ESkpcVzqS9ky?$w2O>9<(tXfuO zAsF0To@MdeO(i4T21TqBRm2B&IULBb%d3X#(T%Df-c;4pe~c8Mu2Ne#2y$O4iHwF( zW9<9=VU4-Id_rmIgmU%!FvnkMKTmIAFuU2;{C566tggO8oCV)0=Kbq_G4J7X?H1@S z-S=a2A@3^kWmKRBV;!%n#;5{JWIdWz5C3&?;dY>a+5ndlXDS~ zotM05QA#eKFp(c+K048StTCxB|Ixz7^6Qdt@mS%b1z5G9Klo{c9sjC>=zA%yUd+Q& zJ$C`_$n%yyjE)HD43LL;4*`sT8%g@ntc2%KBskv=idW4c5MRNt1340&JZODegnD~- z@QPPRZHQL@N93I&f`b|=1xJX+_H}ow_Nu$7J?FTP^BtHG(5E4WRoPAPf%su%I0cym zdU5tC!wwW|QsJlPE-|m#+)v|D83+2d3GZ6;apbr%beoj4K#e;Br<=i(NykJ{XIo(q zw=IgwGZ0f?O@I-Z01aLk`_6H@DZ>>ke=r|Yd$7xbJn76?!p_s-Xpl5}E?OBF`5-d^ z+D(Ol1Wb7ZM$*Xu(Fqrw z)nbMD9N@M2;j7SI9F!#NR4+Mhm+%1XWw8S9s%NUOSEx^$wqP*xdTth-RnW{(o&n;a zY`9$HZ$kqnBdTpc1(U6egYXLgpu}YZ(2{^uu=ae&fBT*SxSIbG-;-@$kI{uXg)T4@ zs38crT_q(L)yw=>W=IX-z4zX;`ljb;49dF+o!S;QbjqivYP&7Qlx7XfeC|E70q>Z} z)So(=_4ueiu%)VIA#zW6U3!?+=#Y@A07aJyudj$s>|F<5U7`~kYlr$CZN4JrS>G#q zJQPV?2xYJAz!Nk^`~9dfy6iDtVy>aa=FFKx?GcQ2dkz?f3Quhq#5HL7??0Ub*c6R` zyU&5vNPSw`jvYH*K_yMK2GsK}ILA`&b?xAF)?lc;ZbhdAOAaMqCY6W3O3jFJ#-J!W zkh11E^t;`nXJMRhZwY@AXNqa=*#T%Ga2@6v0idNdrGORVQs9=|N4TSQG`tSn0cM;- z0NecXzWZ#{(X%$}2U{C5;HeMNK!EX|cJsP7R6mjN^uf7DjjFEfJ`3gG@BWS?iz|S0 z$zl;FbW~_u995~KWC(V42#_rByHO=Pa=PsSM`TDFgks1qi~{ei4*3b@CHz&@ow-_$=-R(jcSUnx zjn~+XjdhP$6?X_@wgmv95 z98&NraM!b0Y$mLAcXd&_m@{hESg7!o%*Ka8z!DrRZ~(-jJRo`BI}0Smsf0=i34}JC zzJuHcBgl(a$fJ_GP+h{8iF)NHbnWWdysOLnlDZa;_1x9f1u`&!K)`&lz5Ge5f6Nyc z2nmxGDjy;b5OVK;perXYfgoRyqyyj(aeU~kM0J_Y*L0afc6IITlK%Za=vG~+{Sp}` z`T;#PaBNbJA5J-b=upyGsn8G7QVqN=>d^~wRp56`#*Qw&#{9Bt*Is$<{ng{WFciLX*q^-QJuQb~!Bmf%GNc5JigIZMHuNrgI^F;uU1?zUZB zU0u7PHXKcz8f?_Y(`$+L9^lpcACfSgS_s(i#89{yTz*k>3#C7>pj0Ur}F%Jbx8epLx8%;YR_)&8wM>uf zziKr}vq&w{d9LW0v(16r1;>)KSB?St1g2041^2 z_Sn1kip_iNQ7iZqZNCg{sgn~Ndi0QK1mt=ZHtXy0cJJl4t7i7UVB_sF_)^5Op`jt* zcPY+-a<0hAkR(ct8bBI$$Hb zb@SUXM1`?%8(6a%HoQWx;rOJbi@Y}YKw=v@8EKeNun}CU!D^fys!1%v33MV_V^%c& z$n)cB6k2W5xK-;|iIL{RYy8zCnPZ%<83FG;IulUvdbPN@ySt~;f|Z}ZUk&5&kaJ8Y zHQE6&rdIY}Z|#lC6drZnUVDx)3pOu4@cz(;R*>4;+f7q^bGNu~|J?)`&CfnH#}U{L zV~znE9TS-3B{V>5orGb-S!1m~5K1P$?%K8t6Z@B9g3C zfo8$A+4JXUS8=9F59=%+Kzi7I*vD$6|MYC)+@Tr07VQFF!CHBeNW_|@Gapo*h#G`1 z+OB+oN`sHhuMiT+mgfdrhBHlBp!tR!=GYxPsT=<=9>KbMSMkdvCCsq&8(_$kW(9J- z3Je?A?Ja{lv{bP~X<8KgkT!@e z9nybQy~o!$-c$Wqxec*fz-DUw66VE`rz~;;QVaASI9Xb0XsNoZ8rz9w1NJ5bgZ6hq z76vFFvH!5z)CJaCyeG~T3+9jxoWn@8XRkr>IG`W?W>?P}UFZ*#p4Jil1Dpod_|FAz&yFEJk0>8oT2g2HG7dAi`xgZvQ26miCV+apW?LA|@S^KdDD@Dz^TS303DgO6xQ+yZ!_t%% zO0}=~8P$WmI23o^A1B1q5T`_^3}Viq@d9TCpD2^k@sfn?bJy^f-kP@*z@0LM>*VR87LdsonkYfi~?h<$f(Ig&7)-#)sXq zW1}chM6tmUd?zx{b;mj!0N+I6)rRS_3~g38h-IjxfW3QdIe=!>DWlwu^$sn+Lk^_` z8w~80F4=*W1I5ackJ3HisA})NhGmn2!6iFpfgyW)@`}-2$6R-!>BmrQ`6AM;&EK@s zw;gzZ&%l+lUBotF!;s^Lci*gQ76*e9mp4$mCti0<*XR|Kb-SSRA%23|pMaN|A)j#p z=P`VADEM`|3F*r7KqCG-T&XCS9-OqSp=;KTB|Jn;@)yhzXkEk0LyC?hQrsAb2c2$c zlH3_kiH~$Y$lae1=XO!yABr#LWTeL0fJ+A87LylWGpnm%`NSX(0SVMH;R8+6TIpRV zR>;-RBSXKD1UUkFT#n*?c8NDwD6|(pmETHLMZP># z^DCZ+&z)ubZT@zdIhX&q%=DZ7GX7%|0i=<>RGxdtFTfDYU^qH#Gc1maR5HW5&_2%) zc|9+;iQzltM`io7%8ulHKnUQ{#qhSCmv@$__kl0g0z9TF-u&Ci4{LUsBX(kX@QR+Afjy*?8Te8mf)BV^|;2IFjm!V6?6Iy;hA{~t-@zJQP zGlU=_&qJJX!)T(WhdjR5?383zm|bBNGp;=oG;jUF!3Zm zgdjhV4EVp0AJlrZ)}(1vn0c%kN8mAW}B~o9NKeD&9!Jbc8a5 zZdy?iZGsSwkrZLkQZ;YNy9Ibsl<3ehgyO<2P8L#xR#H28Hsm3^2uu!oBEw3u2;7Y^!QZ3|OYKal@z-Qp4O%fJOm5te z>2<@hw3Z=`h+np+Ph%M?DatVIAd5hR?4lnpkP!IMq53w{>hDt+{U#8|sg)nvFuOHaghSg@)# zi91&Q0hCgTM0(YxN^~A228O{@CQ?luElRD{hNXk}Hu@49u#{=Zb|^ z(rtxL6$@5%&c%|KoRTYDP;Ag9!MBKo;*p+%T90NQgHe0+Nb7Ri`XEio!*)l9R!rkh>-vsF ze;LI)gf*Cou2^&Yo$bOJN?0N-7}dFZ6C$2UpRdFpglVy&Zb0_H#0|bd^dI&iI6abx;2A^?Nc(?4^TYO8{!hAH zjRVU(6LH^C=q5_<*xlL}h;)C%{ZYg41R~!ilEEYAR2q4q5fdXQaVdnf^ITA5;EB2P z3DGsiO=k)w z6vhM2hO=AUosyu5@l&2jmfxG={j5F+ZK{s}l*j}2!Rd6skZ4-SKMd}CU@t9p(DHwZ z;PBg=5%v+>S5@fXH!azP_}6czQzvTv{p!>;bk6njZN`28md`;wHNB(#^xfnEYV!M) z2ed~+WbV@#+0Q`jw#ISnKFT6X9(r&Epu|wvM);szWS_tNH<8-@wuFT3YB9o!6zGks z*fa7HQ1qsYjQaK-Ry0TRngoNTzn54r13M!)c{QcKw`h;z?Nyh=_o%L-@uvHrjb!P> zZ6k4n3Jt6iSAw4&mPLnXIM_-=ifAO<93eLMu9IHyw3;W3R@6e9uN1ZM6wV?^RZB5_ zP}SDfAloKVg4U$F%{`OmO&ne}xN71&vAM0ebolVn<~G6+JhHd)ooHLK!h79a6GG{9 zGpslT(ecU{U-`XmHX#j3MqgRpwMuNYa|^#C8u$j4A1*tTNxD{2sIhzokR$XHNoAFELqEF-QR6>y2SMjR+YM1j#6{i3B% z+nul~9X(SvKf9LC<9z@S7xeF)*2cx?KUei%s(L58t6hFqvHDbg7Y@!mURpiU%CLJe z_XKpQtVr`{bBfs7n|JUvfte-fInwuu`=&gN$;k6Ua03%>{|okJMbh(Wzi9b&CYyu35{m@F}9_in;% zA>g?H@VJ?UTb0sTn-*Zc&>>zhlzP2JY?j6c@+%{|p;>s*tnG_two(7nXg3Vd7U{EW zV+gKQMkBP98fgo-rP2I^T3UP&+L|_PyJ{;!52_TFNKQN?O>oTqq3MD!j}r@fr^*Rx zU5`?|94s#UK&#ui))3JcF3W4~sT&tnj#n(~SBDov1>Q!83qku5kdyypf}-`FVMr{R z3*vx0eiHGw*yTn$agd!zKZ(CUA_gNYl_Pp{GZ}#)_HV}4w;sk5`eDRM;o0_38VZqT zqICt(L;QrawoNkq}oc*#F!%>wcXs>igBNgwKr0ZLS2e!dfW*Z zw~rPLL$+9Taz-pCxuHz@Cdz{t{jupt&<-pj2YR45vP3ZrRuSuTkH_3qo#by@HmK5D zCbl-EC8jr*2K|{M@RsQL0x+Cx?Z*-|iX2VbEH_(4Uv#;{*c`q7gQYbt{FuL(yY@I9 zz%R80t?^8RJurOEC?vp;$;dOri3o{IhL5yuI!CVl_svB&>#KvKGIB-^ z8dvvxy)V(5vtW!&FU~2dqIu{*pMx>`DQuYPGuX#gy-?Yz+c+vIdfY69rx?~I)@VXU zD&QU_YxB+e@q;Q`uHr>4 zEqMR=-gkTdET$a}UXc%~=Xw6nvF4-#vBKh4&CK2_dauAb5ATr!&kNo+Gx?Y%^Qo0m z>^Es{ggFF!;+gVvyzkYOu)!%28zqTfGO#6}p;YuwbRt6E5HpEKN={0MLtb&Oi+DhI z`C{gYJMw@_aGVoYuU)t?Z|Vsxw3J$o7ymiuoQ;zj3QJ218zz0vedE;Y)Az$07p`qO zblwFJ*`CWzH~{dq|)bE7v(V+ z_~G`D&kOTOZcbJvs>C6w2l`A76vyKD=k*S{>P{BX_`73hY;JvXc};9g(C-+^n1sZv z#!`P`c6@aoe2xI^T_<9i3Q#A28yhIr%fi57=4Dh6B5V%+YSb`}%k z?m(`B7&pc>y6-s{ejhV!9f)z@4hMR>Jyb}=D9TPtX3Pv2+6KlIRnrB^j%teda zW{Cf6-@aBeW~z_gXRSV(LXB1*nwkO6glKstHkVzOSo!Hn?GE0rKdfD=za8gvZ8GN` zpuZR8tyFJ{t$!dLIVC7tIxX1}zzv5rf!Yt?Tf}Ngv27rC3y6TjF)24RDEc|$%z+=|?p-SgM_1FlycWA7?x_(%lH)x(`gFPlGF1xWTn2}X&p$jwy za{`{2)}^0a@jxNs)ChiBo`bbdAZSxGX5Zo(wdh3tI>R0k9L5ysFz4@uYFz$H}cXGoh^}7kooRa zXE@^cHBWRH^4{Ufda}yq80=4?vEWDKaL9W1pkHmTw--i#InT*=hxrgWT#2+0Ythye zIRrA^gTVBFd5q~4uw^?rM9Wa-Wq9Y$E6OYK7Z&8FrzR&tF3RP(vEj{y@?a66$g25lZLhx+4k^g> zCVHEz2Kxu+=XjG+nk&s$*KJ<6c=`-^ZBklNN|MJjXechciFC1j<;uwvh);9{^YJ2J zkEV-MMdBcIQQ7V~h)cq8cQCr>c<>16;_l%w$>V1G2c;C-bWyIZjEPTaD9X!ADeAJ; z6Xbj6nj}f&fU-U9E7~TOT#q#wmg{8+&in@tU(bg*p`GsF(;|94S>n+139}OTJx#5@ zw8Q5OQ<4*jH%xO;Z6u2~bns`EaxrK=+_iAQywKE^LH?@1pq8o4YE`y0`Ueg2H@4{c z{oeX_@TTGZuU@urW$oehpNq`GvqLi@dqYg6Samnzh&M+z1Ju!|6tGN{S!ri(x6whN z&&G^=?2WS066}q<^86u@-4S=t-C=V?HoOCElS`f8542Sp8~u5C{>Db^6&sh7hYS2J zZIT1=yNHiIe15n8!xq1rhZJq#cMH7*hvawdF`9Q)dg3N`1dOHwR=8n;a+;kOP)aws6c znAv)u4s2s#IocPc7Ew$(*ZS@tild9>6Bu=!ZnmWZC&7KDS@EW(|I*;uFU=|FrZc!>k*oTfdX)lIGUWPs?UhY7Z& zZ<1pqkenU4-A5VK9!kJWMD$VLNf$Lv1A!@3x_7}y4aLSVKmZM7ls?{4P_>5=qjgkA zrk^rLKySAWdb=aB15y_Q@&{iy*aO;Ay<^b!wnNYoilMM*$x|RDsBWX}d9lpnUMJ?! zR=~aYVdGm1o0P}BK78+1*cr)&>2aSLzV{LC-LLL#2;aL>Y!xZ$-p25~4S4Pyb?@}> zz3t*={sQ#t9`{+{dy(hP2;bW-HuD$MyFUou+btII+hN=AxHpCGZ5J2fxdhxh({ZnV zAHVCZ<<~?1>2aSQe(n+6I~)2*k9%AA-X>WEJLkQ)cXjyQ`*Jw&au4q92;aL^4gp>s z#Jy|6_ddeC`_;W`!}qR~Lx2~0?z-^34S4Pyb?^EC?%fi;w_TQCZ1nEV@V(u_1P3g7Hckth%ZVKgzI-t!MhmNutr`5(O!Q79_ms|kx5EIG*PW7ujOx783wLjnBoDQL~SiJH8QnzV94 z5=_^_7XAApf$4f1ruhh0nJ1sayEzp9k4jOvBqMYtSGrWJAdV_T`SA{H)QAW72<@RZ+eFEO%?6AD^iE1 z?b@|#cS&p3+O!e*<|QCl%X-K0&!M|T3=^9ent&sPyt$BN6kwc4x)hXt^uQkryeRo& zxfejNa~p1*=%~P5kduuBL8*xeb{Z_ovxP!RL;1#Usl9Z)Qhp&wcoKGjF1AcylgKgRZ#**SRzN=BKpDC9Cee zM+}lD0b@Z{%_fAJs|N|HTZo|`Z=P!z9m`lW+yf>WX(r}^iB?`7ZZ@5>q2(pTfx^7p z^wgw;7&pt|*^~yB4DvuHirLwDQZrD*A)c6)9TepskX5ATl%1ZkqOz(g z{PN%o|C3ci!XTXpl*OcN)TSil6jTK#(-5}IwpuIU0kj6Aj={HX~f z=7G#xcKn%_LvPRdyJqof{3^g}SBM?q+Ayk}&EP)_2F{51jy}!6nyJFrVg;)BOzXK% zOshr{R%w_cfddUFFbep)tIcA7qFU)$-m_BC@7cZOqFf9TgLoW(_qfkR-VPei?A}_s z5B_+8KKqJ*BFZl*MjuUQpRs@{1{_{}xs$H)3)97=H9MXCv^Jz82trzp$<)a=MzVSJ`;fzFCc00c#+kp2(K6bI3WW<|^E$C(Bg3>(I|y*1#(9--&32o>dv?Er2YB8I)iks^RgJ0xeXR#9i_!l~{5}zXt-wguV3fmH4Mt1( zx~E`#^tru6+9tgv;aJ2+IZBQel9iX2eXcnp7*5Y>;?ypDB zgo8D>rWH-k&O%1esra0Zu@Gc_7HOGQWTXWeSKl>2GfO{u_YKsq4NV8eOZ$=5a;L(A z9}w%E<;Okhw;8oT=z1M?p1&VZ_4sQYAf-F>9M#{__&m^4M0-|Yjx|0PGpcaXfj5c15Y#8CF-CSd;l*yV6W`JN zECEBI{`D@#PE)6?{@ecTegjv<>{%SUNN|8Wh2S}O40{H=QyeNWCGbSn$&=X6P?ITz zdohN)*=0PHm9RmqhxM{9RBB2`;nobE$v(y|U5vU-S%{v<<~gX}l*jYgbG(2Tvgf%U zYV;x=k#QuZYPGx*BAHvE}rm-%|@{X zehPw**Yb6su#M~!zMehGPeqlgjr=rzIvc~!U?1=w@J;+oRIS>~&*tawE&NNGdW|c_zr#z zo5-&Pzmxw6yLJk{i~SeH zm3N^|)=!bk>u3CK?Bv(jI#kWN2fJ+pzn9;~)}w;fZvFs&kUzv9W=Hc!_%GNr{wRM8 z{OIHS3I0p|EB0V;T^~R&iAul@E&gRUMRgdJ08lW4`KB&gbVI)9uXsAMVyEi z2_jJ>iDZ!?ydqVkiFA=6GKEiMiENQ0av_oBivm$7{30NVL{Joq67YYeqD%}HL%{P@ zh@oPbs1(CRm8cdqVuYv_b)sI36r)6g7%dvb7%^5fiI8X(_h}mL}K=o14F6N74#c|?zae`PN7K#qhDHe$?u~;k- zCyJ$FnOH7PLiW0o#Y(YCtQMz;HDaw;C)SHo#RjoaoF+~eXNVt&P2x;(me?%L7Uzg9 z;#_DF&J*X0ZQ=rPp}0tF7Z;05#HHdgaXHxTE5SEkEp~`&#I@o&alN=f+$e4mH;Y@u zPVqx=tGG?vF76O_iXWjm`;Wyg@e}b=@iTF^__?@8+$-)A_lw=)0r8-CNIWba5x)?R zipRv`;tBCf@hkCb@uYZ4{6;)2ek-04-QrpCoOoWmAbuxa6fcRF#qY%{;tyhv_@j7L zye9r6UKekOH^pA@XYrQ!i+EeSBiNBmdp6W@saqDPpb7b;5fjg}CnUD7Q*GKSqCV`ZF-mkBaaCdp(8 z{fbPLX);}A$V};zSu$JZ$XuBx^JRf7lzthIMKUOhWr-XlOJ$iHEQiQ)Ss{nYVX{&V zmsPS_*2odER@TXS2|q>IAVOd9q#3m&eNExUjq)^kx;#Vv zKyH#}%CqEVdA2-9ZjtB8t@1p1zT74+kQd5}c2<9@e#suRiqs5qLOfn`LQ;b$)s&TY2&6sYq88eKT#w_C)W41BJ zm}|^4+Ku_fvBq)6@x}?p0%M`kVRRacj4orbvBWshSZXXYmK!G-D~ywkmBuP#wQ-8E z##n2tGu9iY8XJs_#%ads#u>&Bj7`Rw##zQ@<80#`V~cUFvDG-wIN#W2Twq*iTx4uF zE;cSPE;TMQE;p_)t~9PPt~PcU*BI9t*BRFvHyAe>HyJk@w-`H(9~!qBw;8t^cNljX zKQitzer)VAeq#L8_?dCH@pI!I<6h%F<9=he@qqE5@sRPb@rdyY<5A-=<8k8&>X`HNYU8$}#SGp_1mFe=ivRv7&99OO@ z&z0{ga22}zu7In^6?7H5N?e0nrLHp9VAl{=xvRo8)HTdi=^E~;a#g!(Tq9hyt~yt} zYou$GtHCwe)#w`I8tZCu{h!+21isFy%pZT3`<~<`X_}^KXiHm4nl98(?)GLW$TTZ$ z=|)K@RHP(L+J>e{$``6VkO`Kj5a@EooJnaclU06`E> z-xsVKy>q?uymj7tyz{;HdKchMgF@?occIteb$T1TF0b3$=xy>gdp+Jo-WIRd+v@ds z{oXe3VsF43^oG1)Z@ag{+v#26?egB|?e^a9UFvj_xea^eh z`@DC%_XY0`@1MPW-WR?7-ksiE-j}?)y?eZSy)S!T@xJQa=lzR!zxOro0q^VHgWkV- z4|)IQJ?wqMd&K*u_o(;p-nYDOdk4J#@E-I2pZB=;9q$S6yWW%D_q?aP|Mb4^{lGiu z{m^^b`;m9Zd&Ya#`?2?&_Y?1V@2B1i-p{-jy`Ot8dB5-ud%yHv_I~BP;{Dot)%v>k z8}Bvmx8CdC@4Pp>|MK4Se($~I{lWXA_b2ad@4vlwygz$KyrcL9gl!z-VrkPe#uVTr zW6~7i!t)X{&Xk(*rp!z*pQ$TXTJ(`;Hy zt7$W5n8jv^S!&KS%gl1K0?Vzd%vol&Ioq_GHRc?%)|_k3GwaNI%=zZM<^r?cTxdE> zr`ce-Ot;x+Hkr+)$6RE#m|nBh^qGFM&0K5-%%B-E!)CkLVRo8J%r5gjv)jDiTxvdG z_LvWv%gl$&<>tfY3iA&zalI=gsZr3+4{<&t{+bqSlEkD7ls-!k7e2h4w%$ISmTkDKq9C(L)PYpmxm z^ZU5lKF)>Y#KWUy0>>14EObIr7oQOkD@mD5(HS9@#Ny75f#Stc9%sqY!X-=B zGoxlnC~wG-A*WlLRZh+I1xu8oB|?!|GSt)C)m^+af+iTVwPu+BToy_rmCiOLmVpkl zLXuX*l5)P=+PBH9jG=Eyi$GeG;T9E4i^{G=tG6YPtyXh$tG9A<|3IIXR^nTOQkttp zYo;|dZsq3TzD*qi!&`ehhKCAPDvK=C*2dzMgS{Ptn}y=l`V}MGuQg|LZ1LG6gpf%q z!40j6cF5ec$BNZ5IjKy#(X5H{oE7=C)aiGFemCj2Pv7a*R^`35)>{+t+NucKRAQ|G z@5*y)>$o*}yslAR*GP4GYX*8y32V4g&01k=ZNye`ZC6kCK=)wJpjo?VpksS?@p)0A z5C$zgkCQaWb(6|uRr+o9W*vhqS{Ev$S-W7v_EcT5;A4MT3@JyHhL|-k<6yrzlfLRp zxW2Nr9583$D_v_kLeatss5UpTXw$7ts$NYja)ep5(0WyjMiq5qU`@1KBUcIFah2d( z;WTCoI#iq;+Fm=lMT8q7DHUyq`0iqTFx^a~=?=96v;-|raW*TCW~H^+FX|5Ah@_j8 ztfr=dZY85TW;QaulH0`Wq%yv0R-11&G3Me;5tRfomCj^~HicLco5^f!j`EnEP`Igd zODfs3ptpZRZ~N*qg`Q@`*W6IhqxAHs(dc3Qa(XT*?72wH4&%#Y>xwUm;0q1ex{@uM z2D-cZdOP~MdN!C|mZ+pPG@0H|Xs|BLiC&Ov`b6k`9ozZ`hX(q$ZSFRGv23|ow;M{{ zAE!RB4S~f`S+=OSTT~UKRl*>uN;J25{ZZ>{QI1=KQkttpE3-8{u0OB!^{ZUO(lizK z$1KeNGhqf|g!^@7P*@urA*D>Z!Rzhs+k^=S*_okOp;{&ecBaV;$2r!nQfw;fk1Ykh zwNnXfYHMI`>b&8I(^i#Fo64&-;9NOwZ5ub7$LVmusoI%gwll+GXLblvJ0hl%J3>3N zL+s2YQKG09)b^6Noe_JP$*Szz8q6*RTeK_G;PIQf2ev|sI(r8fZ0P76GQDIg(#4`u z>SxihUkyq#2a=>&nuTkL{f475Ns>B6WQs-f?3ePj*(iWY;DFggUs=h}=5wHU6W5OV zLn^Xmu~r!?m0_^i@lpHaL~{{+v-ZHgm=Z$HL2{2@j^&bJ@(jVvMGzS&BE zYRwM%sw8rlo`8 zE%87b2g>srsHRIR)qpfr4is^FWpA-GTK03*r0eiCIG9NlOGUAt?Q<^8!;n-i&1M4M zGUmyp#U|xa`Yj{cRIVXN7vq#mOOKpO1?6QhkxNTs&&l`-aHT8DrNxNlQewn%X*Nqp z55m&*<3)B zGsqW&1AYTMN^eT>q!dp|@uUKH0ey-urSvx_T+C~zi^<9SDV1wdeq|nou;$awAg7%` zPKL;-oD4Sct#rsRGnH!-Lz7F(po?@8LT!X@Xsbp|!~zZR6gFVeh*G&!E9sLouUv-o zxIsmeRZ_EBH8PG&<;2dXa(B%WQegI$b=aimYKxdFI2nYNI zc$8jW@%W0zS3EK*OyzO`eTvTyR7mk*a4RaL=`whR>L@&nIyt>X>1k2CGWLP$C|#|Z zUq%p6A?a>%emyCQ>XQ!QMW3>9t?~2piF;G&KXSRqC_^ja`jiuW@)~=$ZSF`kbq{rT zXLM}c+Q9^88*&A$+Xj0udP=NAlG}>p_Ra7mmSC3eo!PN%TL-4HTRXctoaMvL3Y=8! z!4$EFRtcO{J?`qw{odI@GJ%XmOp@{Jh_4oA;mMBIJ z6apiAl7vvw6}|)%CI^aEZtdQrnJ9bEiHz4wL0FJBw-qt{Q5(!Qll4#4dmSu{PVnh% zB4K!4-MvE{1>Gu&T}W^#afn6HL!#KiqUdE&2>hmIr*GKV*;CLj@^A+>_bcX%H^@R6 zf>4H4DBB>A4fsdU>nA>v1Xq|OUqJkXctDaB1P8`zAjn=ZA}l48Bn}iYHt{m!l0}*2 zKp~iAkI=8v@2m{`Q`wfkKuHSpRJcKZVj1qDkDLR z!6~s>zFOpqTORUAFed>Z!CY2C+^93@v@bz!(+DaS>HZK_NR5(9kmdwwPSaW? zt&P8YHeQ)@CMQ8b>I+g|BLyc`JBS5LMfgkD^cy532z^253qoHI`hw6GgtfwCtuR?D z_-X}Tt>CK_e6@nFR`AsdzFNUoEBI=~^fb0MCUQ^$ zy4hFM-Lq*k4cyZW$&EeRDKe9iqFsIcTMM!7w}3qagFHn;n=t`pZ*pV*@PPWLDCZ*- zoNo{-m3`tB926^v*~jGd4R0M_9PA++=seK}|)F`Q}veNsv=1NDVHN zsuvYZ)pP%eF!w+B=6(m?qWYuTJ6BHBc`ExEYp<257vK=8lIjh5U8U<Vq@{iF*{d358LO?et<7m( zomjDE*|KCuU;j{dZ+DM&eRX1a>SS1#sgu4L-@<#H^w9|O*aF{8<(n_TYD(XF`C8E1 zv#DcBUQ|3~UVcg^W^UaB)b!RzUIsBWKO*Hx< zn%3JrILH7d#Uj>+v6ULzfrS-WoZ`%rnb6+ujY=~4IAP8h3Cen00<)rQ-8(m+s~98C zv{88^E8{7T5(5cL<)|^`SV2i!x`%{dmDr?sWXLdMMOJ}>w%FM{fDXG)ux>*=^>?k8 zFOHg$7Zp!AEMm#+_!76B-ih`7#-m#C1F$%EY(-Y}V=k4dcxgX_oeah7te_gI}y&x;z_7A6F# zp@fc$$E=SiuLziq*T%#!k_i@xhH-(1qfZDz>qmJwJpvWsJfUL{Ewg8E%lc6sE;A{N zjtg{R7%MACY&IgDTfePi0L!~U+nN$f3DuvWA3q{RRb4R0(qgQ~Vq;Y>ITjr-&rqhu z5+W}r&5pwB$MkZp)R;;CLxD-L0+F2KbPV@(p>1yHALwQb)v*Fm(g+z&WI>0O#0972 z$K@xMNn%7nWh^45bmbBquU#$535-aK#sG|tj&fH>zD`)KSY#MnA4SfHu?uUsJ_<7Y zsZn@jQf0geab_%1r5mQjqhm~Rq6|x9nx09?G(D3_*CwS97Lg@KwxtvUMl24K3U{EY zG+O%BRJN^1)Nw<{V0WdMKiWCQE?O7wmozuFA3t_9)~E-&1)40RwYAA?9p5~=&eX{$ zCsS9eJ$apsp6Y6OmY&JVLJ_{T2XB_qPBtZ@q^v9;W$I*iI@6$|sfPOcaTgDRCE9u) z80_v+Dg97KF(4R zWr;L&Jn4Y++nnjRD4b{VCsKUWX>#64L0r%Ua%>651|tm(#8H$5arovElVcHiNXvtS zh~A1ID&BZXDKg4a83yy9G2VEwiCz7@y(k%vE8W&RJh+}5CT(IEjN&G6T)a>jU zv4|L9$4PX&!7vsrALBqXQuh@rKRF&9C!kys;!#ucqVjmGkRO z%2~p;R~ZKLP>R5!s+`E#L|yV(khm!4^jJ8g3?f8E z7^^6O6_LTPgaM8Wi%$uI7-8hirUb-@$Z4ECip&$(2_qAue2iZN8jTP{5Ml`899Bl< z;*Fp@7TqBeyW?U}aR^gm@sXE_sEWcdj`R2s{IUZWH;Q8@8?6iEqzYoX$+4mY$0{>D z>LWuR(?@0fxnW$?wMM=u-;^j{qN)Xne zj;sw*L2x1!5+HOU8`_#=?EK$6#=j zt<&-i%9m^qfgj%3K{;6f!|&Gxeopz!Dm}761^NT{T23~wK)05Y4J(9$d`fRHB-9~e zRyLZDPvxL%!1zJ6;Fk?6;FHB59qb{$%1eiwS=j&sAAy_#d`f?9P+sdrhpyS$0I$kJ zHs}Cfhpx54U#$*FYa2yAwQ|}jQzv`K_?Gf@vL~FW%hr}-D$&=!qqn4cKAZsR z#Q+M^i)~nd(g?G)rw_+E%^*c|F~{k2Hc3krTe0{Q#MBmdcK7yb8P1iHJ>g8YzG>Ws zj=l}uLFy!&MHuMm*wnqcduVu|FJ2}q``nqV>^tLIc}%N}(#?~)I)*xy_4I9N9o*2d zEueB7Hf6epg3#oy?(IDrx|@ZiHY`LdlH>9cR`vx2rsT!9(<1hmCE9oN=aF0ASl->& zwQ}R~?yjEUt(sKEQSIG3hcsfcL=1HF4Q}lj9K;fjCQKid(1e3wTQs$_yYJ%Ro`Fj= zIIcU+aK*-+-tM!zu^@oWn1H?U8~X>gwsm)RX^}~r2KtA$jm&jCPAB#3f^0f^+dDdk zdpl^+NlS=E*Ee{Z`-gQ(g=2dXD|_-dpQw5*d-w>8UZrINGn1CRaMYdZb93wXUhF8u z=$hI`TXR^kZycq6rG4EyD7Vs6+&7%BRoOP)!^mXYMC+S^R@bH)(k2tUY|5`30F;5Q zk?a=_bZ^5srtYnnTb2%P?%6oBB;bBL2UiXU6;~2(2$KefItGSTZd{DHD~^6Df#n(- ztL_Pd!<}dyLufUcsdDg=z73nP4GN(y>FN!lr7_hDL+Sd)3Yg2Gk>pOnymF`qhpnsFQ*wZsm9hMO{=-1EY_Zrx>7EmkzM! zxMXmknT$D(&&jtMU(EB9U~<#IFhFV!HP4NrHW^u6&t#evgPMhmnxl*y6#+)YAZM5} z8Jw6yo2FV4xH>b>v`PhMQu3XuYc59D+Oxf*w;M)A@@kim$u<;s@9e=!T;EVnM{gkx z3t=lX0I2;_M*GbM?a%7uOh+awr&sZJA-4I+^@-55u~dfS9_bO6Z@TM72B0j z`|bU zpV4tbCe>UdyBd*9bsZw3T^{-fk)I9`GTL=zv@6WCW|9Cw`rWOyQtuabZ{0R@2_lMm z`!|uIV&NjG-u{7-9$LXc=RrosgWT|o0aheEsgshk#F>m*k!*wZ)(zU88??PQXiuQM zTeg9v*4C<|rgW5)3BOfp+S_JyRD|I}$v`*#I6=554Fa^}qD(cU)xybnX4J6KB z18NcF+%wXH{Ho%r85tdc(CPNUo~=DthY;T7=uIYr6BR6rKy#GGjPRDpD6bj$-2(D! z^~fnOgjLgYQ7TiLE!l=syx=5OLv>AsgTos(4{h#{Dof{5r9rM3(;4{*2#~6JrE*Ce zX@GdJo+@PKnvx)w*10-lNiQR(=TSzjg`8~xVNz)st;CF0DwfSu0JWtw#z*rW?j7pc z)_aLYHxx-U%?}GOO6cD)yp5C64Ru8v=CuZms!wuMSN{&tsdPhw#Lx=4=BbsK&ie{5T>e@tW>vX>1>xQzg8_GU5l)L&mX+XmGece#@bwk;&uk$o@TP&Ai_f=cX*M7=ZefG5<^3`(t za&Q3f#X|eqKjBs}fvZ-~*CB(i^Cw^DiN4Nre4TIlI=}IC=;6z`{EV;jK40g%zRo-G zh>+ls1CSYC&dz3hIcu8nb)ewuT*B9RoS#v8<;*SeOZ)XxDknMPn(?)t^>zO0`_g`V zoj?0J*zk2e=GTew^X1?r=u!IQj4S97EAH#O*w@1azHT-6y4B!oPv+}*z?U-fso@sO{B3qPlgN7oU2T{rME8ZTQokduaWT;%IP6u&Nrm(Il3fuXPK zBfi>0U&l|rZo2q&0iR+oeVw29I`sABEN}*g&V+BZr@owth90OsXJowQ>-vnZ<40c( z-lDwLkB+;1U2pJpe(&pgjIZl0zOLusB%I<^JbFmY*SWB-qY_`&BYYj%_;N-XdZu{g zOg6%*r@GGK>$;1tGbUfhqrQ%yd>tYAy58dJ{LYs%-{4F2OGj$H&dGfp&-%Jf;rp#c zu&JqR3Ws_w@#HKaySz;|E;x)BJ42|aw6m$YB%kNs(7&}a$v+PuA4vt!nu_JSc>#Wq zqH(ODNaGqKj2hpVNpv#$^)bCk;JY5Q!tsRY}TSjgz#5oOXN z>^kjDW%USqR*$gjGF?iSb?{xUgw}$fbj+WM`5R*Xtok#t{NxYvlRwB$e$Ah$B|-4X zU+iA8Y&C$NKwv+#G!&%-LwUxt;#@GYYB`+9LZD2<%oL1_!1>_(95_mxAAz%55kMS4 zjsnnzC@aY(H8LU(I_1cNP7!&~aoQOGOT(IuBg=81Jg?5<={VaPQkE9HB80REK?5S5 z32~)}70d`0$&2cUK;qe#SRh_G8JxEcF=qucD=ns#VbkC&rNAn`o#tSX{?Y}7i}aT> z@J@*Tc8v3lm&J3M;Yd0C-hkgtPA1tD4Jt0zu-NHY`f@XO0@An?Z5c@pydGO_KcQ z(3?|dBc=}j4Y&)-#@$w(aJz6Tlx=+gAKUsMZtAe{EBeE5U$NeSd&D-DZ5QGOGTWYP z&xBir+r(^p0q*{?ar4(2xaZne!o3PNPuXZiC2*HHtKqggpda@< zy$9}laa*YEbT}K}b~_v4;&vyvTbwO$w>n$l;)W-<1I_^4VP_ZI-OdN$ei*Mo*|^W? zBXB?JTm|>z&d1??!ubT;PdT50`x)mmaBp(BfCx+o(o46M4XA_@=dw=47xDO;Afcw3~_u&2@@dLPr5|A)%!uXD5;|_;ymWvx4 zz6uw&9H0j2!e9$`=UCQ!{6hj(ad?Bn4dKlTxOV~ik>K(c%3lUpSDQ~-Ww^`XPTYJ1 zD7X#cL*_2{EZm(l8F$ndNSXKTm67VE;TTefaNS`v5q%*U;93vpY| zQoc=ayR{c3>9$jY`;`lE?~esoH|zIa{k~nl_v!at`h72Qr#Ad+<0>%+}o{3kmoYoTx(m4aI2J!JJz7bmV=u#zRcewU#D_m0Kky0~&i<>boh%K7-8fe-moDE~ic z0HqC?oe=jfXottzQOep;iTjwWNjHx8e|1DSNV3p7b1lUYzvuq}L|B zIqB_6s}fhIRF;6Ea!^Q=T`06wo>sZDvZ-=$Wqaic_;%u3KI@QUV`XpU0OBqM2jy(h zEbDsQ_B0juKYiQEu(itC@5LLC%k6ErHE^B%5UkaD+-vtsYXG;;m0E*rL56Vq+iGi= z+x&Li-FAt!gWK>voE&J<2wxBgL|!Su-?lwbuvxEOw)FzX$RA^lW77) zplKJ=^ggC(H`DZfrs;!hEiPkg@gcSrm$S9_Fw=SkZp5`F;RfRHzXbkr4Ns26$MgJ+ z{)vCIEc%~RjeqE65dJ%SJGlSoMBJun;ohTWSb(*-W3Yf_KaOQT3;ou0NPiG_I-SI_ zKbd8}0Jm-$)IcF_-b~pi*fXs(`motnmg}UB>ts3V4;TI#`_eS58oxPG>9 zJ@i^zCqD9j_<1l7_CACj{Qvp6dt&ery6;n%aV`kQBNGo;dnV!v$G_o&_qHZF<*)y3 zAC!3KfAM*%{O|N3{s_N*2T&So_?0~^vi6+zQVTS8*Dqi$yVdvdBpWJ zMp-|xe#92!8M=AXdX{zeIUY6rgl^EZo@f1i!D)1wte@e|%N5qoS(jhJ_~*TN9izkP zunuGV(~Y}2Y2@=V-DGL~if*#BevQ%2ZtFL!*RQel`7K+Y*IB#YWbOVvk2>D6J~CnQ z|LLcE!r%5OnGk&ba=9R9$zO?sI3=jTcYtB?glc?d;D0`}VmY*;4q8ED?>cBgC#)Ft z9P6$3K^KO31iq6;;FrLX-GRI5_CX(R#f?^ntsk&f{Mh<$XvMF2O#T}llfQ+#fT!bL zVOTZXD2$Og?i5B(gL{Hop)V)X_}osxwq1;Sd3!&wZpnxQF(q_^%opChAc1mfC?TjZ4cgjh_or$Arlx9;@qwmMPlr)>##dY~Uo=siBv#A%j z=KhOo?j4#<;SOl3vkKJN8K^_5tp=J+*=?w&PvAb|PobXP&-L^*uBWfloC@`UITh*y zb1K{pOf__X=2W-^m}=-rnp5EhV9cpdl4|SwG^er;(VPkr$D9iC#heN<#hl9i1=rat zG^c`0F{gq|F{gq|spj6|n)?IS+@HAS{+ni2_B%AQvj0poE65i!D?rDb3OD;=PK6tN zF{i?vzL->;XYr~9B%YQ%>ff?4)^+^=70}1hueKobI=;p9Pai-&EZyG z)EsbQPKDcjss6HDe>tu{pX;xd=2W=v7jr65gE#`Jzvc6nDY>KM*q^^#`ftmz zyCnY;FCC>^!_vQr<$Dvjm0U#SmGD#QU9oTS$3J_CA7@hWkbY`HAH_tW$QR@sDn38< z31U|jho7QH#`F&%cBtry@N=N(i9DPEq|q2FiVpGT`{agYDng%>jLMDW3!q2e@$!*B z@zJD9^}tUsI3P@#zn7Cy!b;m!ZnsJqOk>VUgytSVOhfRj(m!pSZq{EVAdDc z7iaKKpD1lC-<(nDqK~Ddi!ogJGGU&D-{MmG9s#8S;i>$Y8K#WJ6zIqQOfAuqPx<*8 zKED_;;?L>D=j7#SLG4FnIzvCz;nLz=9Hu&x&q(+|Jh!|sR}_0}A5qcCxCJNl#939e zF8Orw#eyg3pFYVK;d>DOj}#mzc%rDfXkFpV!qUPDd>2wE`E=2`q7?=01>~+PxDYWf zB4%CDjDn2?1N`ZQyR+cZg3FT+6kLV)YYRSIaC6ZN;#3a9S8!VxTTQV6 z&Z6q%!v*^&SHXP+cjKQwTnbR|%@hf|(Nrg&D)>JB4;4IL@KT{eJTUzz6NA9?gj@vN zL{wH6yi)Lb!CM9I2vrP$=_{%hm?9V93vn|;*ubNFD3yiajQ9+4A?Im@uNJ;hIEVjh z3S02~Mqvj3^@XP+v=r&93ePD#AK#sYe=Jy0RE{$3g_{fe;Pw>`K`sWo}s{Z?r6OS-&NWnB;SaLu=raZzUh+@23hD zqXh&X(kD7%;4d*RZHlZ1ubKe77;-lDUQ9!KtxKd^gH-4_va5HCfN z`$@P@<27NzOJxtT`wZuNp5^m6yY~z4?0=s9kCTfz8g_l~3J+w3*L28V!~PoeW5>w; zAm_ZD%G%4=T|_%Nn5A9~cM+#<;N7DZO2zvlfQ)%bw`IE}>>tlLze`fZ?ipO`ZHDtE z$2`F91?>Jmc9*idh}~MMDSHmpj*UG4xJ%jJ&i*u~)>4eM&cPlPR!9l{M>x-B&T|3# zYmU5uvUAyA#rc2FIS+Hp6AY(@-FJ!0Wlv;xuDA>VI~9O4kKHu8*o#2Ssa)z*_ScBZ zJf2TAVyD$bUE3Y(r}yA(yO#a!>_1OqB%X2>b80=+FLI);ogBwJO0M-RyZ5mC`?>5= zc2}}n!|rm<^9L?f&2XwnbL_PV?B3Zm?Aq$T2r*}I&KkT;iXBkSUqg898oZo}R|`2c z?Lw=;5p>wTg!CDu$ZhACG-)+b(ROS@Z2^>{UD#=ABZx<1*63eQcV@_h% zrxs+NL~yK+5|7qL-ESjiBd4CqZW+6a82+gY|5R#^_8T0(n_=!||M4t`=NQiGEV1)B zKCLd58e%S%vAdFasbOB0bDk=KX8nQva~We5;esZA6J^)p#SfAaxwiVBfPXPdY&v7C zXO6zcRDF%%%x0?I=F}}5vq)UdW3xZaxXxmK4f9yTGOS@vw=n!#uG2>u&WY^KW4DUk zwVY=z`)k<0l;ICE%$4k4$Np2f)^@Y|HFnnuUUq900_o2wTxu@osbM$GG4mvz-8ARV zvA>4>r@~LI6Z@-fp*K(WS4(++wH$AqSlCTX@~-CPysP;X?P}W7XjjwTOuL%)eJhr= z%ycZ;+jI(PZ_{a~y-n;u(A&pV3_aRErCr!01~c@U$~c@87;lx~U6%5^7jLSpS=dvV zjUC;&Sj9ex_K*Q-*8c+16yX1R!J7JagmwJi5!ST7BdqDifMw&=od0bdoNx@7|C{=p z`FDhM;ux^r)qzu4c7kqAwoB-!k6pH5!`5wfB`y<8+cV*2>^X3=b`9K|oq_Ay^>AzL z)8W?HEpY4YrEnYURrqC2%035f+CCp{#_puw>e!p%`gR}OT6+j?oxKZgy}bu+gM9^E zjAdUpx+uerGvOy(5awX%ABcaWpW+?9{kyOu@2`au$n)J7w;&{ zy}5lo`#;I<4eWl3-A}Xo8Fp`E_aE84iQUh#do#PYuzM@J|HSTIc0b4NZR~!YT{>^! z+LzNidK{)Fgl+r%?CxN9C%e1Z-NWwt*rh#k8?W<$C#-N^#i7e^V#BsSjs!ZzVcXYG z7WRDtK8JnR!sl}C53>6qcCWU-;4Vw7GA|a?7qk^@FSrVG*TV&G7cNfTidoCE$I z?w|S4i52fYbYj(syWw7S;=vOSSJhOVUiEa<>$BQtZJhPgtQV_m>pQC#SKnOy;Osed z_s?E5`|8=Z;yLBAIn{G+tnZw&f9|~c&bdujd1s8tF!nu zV)n{hN%qs(Be{~?%-m4!8o$PG^KbOOQft+|QCnKuS-ZRTrP?>@Ox?1&jdlC#?yq~i zesO(g{r3777wxZqvteM-{)WpJRxfN?`1Hb;Pu=+LL#OUM^=Y^-pVod_-)YaD_UgM= zy!*m;Km6`P?|$|4d8aQt{p#jhPrvE(cm84OKV10_H~qsO7wunEzUW%G4>vAoT-Nwx zquut$nSJv_9K5vu$D9{tojK6BogO=sS4=H1JzWmA{+ zF1upcp=EC@Z&`l9@`slnS}}h`%Zg90xOb(qa@xwPSMFc=&Z^2)PoK5-tk+lXI=edj z)Sq2{_PVpLJ^R+PzjF2q_`KF$)n41)(tcU{P3Doy1hK+urV5NtUY)= z^ZWJ>a1QV~e3#(`$u)Q@^Zc>i^>e>~mvjd5-uj_8exkR16`+MiKwbD?OpA|WU5k%v9l$4n9q}X{@LFImz++5j+h&Bsea zU$-)NVdxupgOqIyWPsCx81byY*{lokzU?+^CuDLZD7X>l*XZ@Q1J+Y`Rp=$W%JmL* zTg&l=>gjk7bs6^UF2H-Gz4j3H?ykkVpikiy%aoPCJBBCV1;k(DeZER}rt=QIk8@9S z{*3R*Zk2Nc-&5RK&QW|%b*n*yi`NckyEy-aHxK8y7_H&u!?`Y=PQ?3%^T1!i{D|Hq zbT6`!c0kTXW@IJyB+W7t#jAI-|KF5cUTU+4QP4v-XP-o z6F5od?u3tCB0R?`a6bb7hw%R3#fcfNhm&bPa<8z)yDRa%3a{*8zMUw+Ykxn&`-y+X z3yObo-$={^gdez9BF9JF-GD#fz8_SqKzI--XXCpKZz4X!u<#Z+-V1!w{D?w{6A?Oy z^dDM9aNFJ0c)hP6Vels7kKH49t&j2)0md(odN?u8YqO@nw+HVde$agj@A*wmOyIW` z<%Pjt;T6UU@CM<}@aE#vc%AWg?%Rpk?)$7Nyze*BJ?bt+*)#Ck;~(AsP9*UfVW)c` z-Yk5<{W%`4{|(+I{Db>niD`Jf@DN`7t4b8RFC|LdUnV?vTjB)wGOGmdL7oSC9K01c z#od5vsNh#4U$+du8aNAS)7|CJ+3K<0$iy3t*Wd-m>+pKxr|@#)E&NjB9sD-qm*ize zezEVPiU0Iw;yt-JcunqP^nU32pph&bRG3pzGK6B9s!S_UswfB*x)j4lsjYE?K>V&N%Xn z9l1Ph6EiJ)$?06qi%O$p79kt0FQC)L4mi<^Hi(mORd_+zcE3b<;L;yXI^rG5JIDtO z5IQWJ;i44DX;J&*Fnw1X{ah?$-1(X>#AokEOg_H5InUh$1DFUd`(yZu6d&cZF!u@Z z+2vxON|P*%8eiTfD; z4u=T;IHoB`mzaPqqQapT2uxJsOQUE!mnA+GG@hnh6RmeM=Oo*Ngkq9#IF&GzDQ3wp zF*NR)Jc>VVkIJ9O4yzYjn)(gUi1yW{G)g+v4MubBNpPti!g|BY$5bK(*FaMOxe={6 zGN{^NyVr5Jf~8mqn=Ddv(28i>OSxV0hapK(7@V$@ku5}SN{>rVs943)qw~zQz(6=f zI;24ox-KR;;)5_$GJJvWf9_E0mxiLpCFC7*9_&)Q5iY`!5X_8wq+OaF(-gr!C)6^cmMEB$N5907%aVW{{t4%jkZ~6x zMDh%!jgz>f;iL6r9=F{JYbI**qg;xtF~M~bALUj>{-90GgI{oRdvTx9n!OukBV2S2 zp86|iR;R`aZ2Eme)Pe|(#Ka&%)7ug6xc7_!aWwVN+KO`n9JImb#q+2+>8BlIF-*xooF^IPc#5^lOWeD?LlqC2r`8kwtC9n&?7y~Q| zIk%J~eAMcwJo?n;+M~aSh32_+5e;)&Soao?g`T|q-S?A)X1d8v(;sOc>>+%o!Ohnp ziV+;noOj4>xO*bVb6#P{Eo24fTsCJfEFoGP!_Jpk0H0Q@19=i$8bglkBV}C4H8lsV z1{g1oVcf|WQ>3kxUkN#JFhUM^5kEm+guJ6i6N>_=D$k8E4yQ7{4@UT~{zTe0+7dJ) zPpLJvuFaYQT?EY{QEsg^T++a>QYcVAAM3#7oQ1faY0K&jf4Vv973ON0Dc`yn-w&!WC_gx*mM<&H*57b8Rz?u}8hn{(v% z!9;~{BKU+ys*C*AwRD8+&Q$6G8Jr0?LIKS?4#h&oU4#(To(XfjjF>`^NcNCLI)OZ} zd3caxW?d`F##syBKR)(Ih4=!s(@=Q zVOeJ}4k=0a&Wd5JIC3BsGWORHBI-h}XD(z-;J%~oUhX@ju4(0&sIX}-6uz_J=GzsD zp&Y2+6W-)jw=RNi#@0eqMI9Lks9xqT{;^-5xka0<)h=WjmI_t@wZxHo> z7mf!&2}T4oI?&z#e!}#g0CN}~ZAAM^xMmYkKVY#(Po6W6k~$KVrUd z0qVF27NLgv5vD4daX47#xD&Xe7^?k*%t&|!OyMAvsD3ZPsv)2bW7q`#gFJv9(m$_) zwQvT(3u&T~mUSUhq%%3D2&1bA8~J0i?_idGA>);t0wttm?mmoDk5g+%yv|22ybO5u zU_EfJb+7dpjY#G}U+I79gMj1`w0TvQgSC8`xk$L&a;M&8oi=+>7-Lz9saFEt{MieF zxVf)d-qa!Va`?=iidYA0kyx_{)1Ls;_PI}l;fD}znS6g3z6;@8^|Ktdv5#v{t%jet zp>-M3C#GiZ8XD{*CNWHhR^1{! zE2A;ySYv@6*0Rc+gE_nCeKhxV+-m@X{;2I=!+3fkY~x=BHmZkEo5w%RSVWuiTl|*M z+8l%KlAmz}(w3Uu!nlPF;<7h}{UWNF1hsGB-k!Z3sfAYIthZ;qW|dc$>rABud{rYQ zf0jbJMCEA4ax(c?YJ#udPYS5KNL=J}(#pFpdE3 zK!{&#;y{F_`YxP43i+D5o28m(X#qc?ov3z+j~FL5{3NlV5K9mqZE1!=%+6e17fVcy zuutF1?S_6)P9*~#ME6MIBkk69s_E$mW-q0c8Ko0zz@S6qO@5+`%2EvM`}EV;Mox_D zlGXP=;N3X}-ksDgv8r4dU`3k>v9h1=QAx!rmf1bZwSqOq{1#)2)E9GWr+#7NN(4rd zfo0HH=KEASSPckcM%W5UK^px*auN!|uBH-&GS?Ja@dnyiVV-PWV2v1i#qs??9Vrc$ za?9J!GeWJ87!8awk`t#8en4RwsrMZK{(?OG7c%~7Tnq7*vj@0_U}tFDLmUPiA%GC% z5@HHw=6hmz$a)#B4aym_UK8$Md<&VA%JLN1Dp**wX_lREC4Ryaz`hLrKw~N1vM7dp z;A+BZsz0194cpm_6)IKK+3}y|`5ECLOB(7C`w7P7$UAy|c6lIsl%QN6j_^%?o$)P= z@J)Z7@nHr!;W?|M>LIov!WULd8E1ZL`v}u6X$1WvfIhPIQJiu({ZhtwMhs&wW6W6v z=54F2puF_H`ESo#NHau{X2@sIx6tZIz651GMOrQ9dywFg7<|+VWScbi&A-_^Z&jFm z^RKmLqV4GH-Le#$d`#4f`5pO1OfptOnc_yuXtlLHLdn3Q}shl4rK*znIpGuNsKgBLnN+T2A zDdn8tENpet_aB2|nxXO>-AladQOu$k3)V`45ee2x;;kR_(4W)_(Dr#-;9$F|G9iuy<9ZtJj@@tHijpHO$E}uIR8^FM zyci4HYJyLBDTaCi8MP*vLfH2F9&^kYA?Ugm4fl}Bfrciz>-OD&6C*k`XQ>d~) zeu~@6Zlq7xr6=g_e&|7{Pd0i>Xj_J~$2s8KpJ+Mha##u3{Tkg$KOe(GTv`^zD8DxP z9ay-{5p*6u=vs+2Z(b+XsoDbAymJ@K6$u~aOPHm}+)={9kKp+NV-dP6zVAn{(u&Ow zLmFtu5f~Up#A7M**hY59S!&^T$gr^jDOgR6Fb6RNJ<^?hfH1oM+BZV`z8KQsVE2Y( z(G!!!l}ttcXkYaIPNIx$Gw&_2t-$Ugx12yiL73>m+*w;$zE<4HGLoEBD|f}{fkgp5 zuMk%ZIh4+%X-wBHD+w!#-lyP6-r+i@x`Jkox??eiATwQL1<5Z~ocxjOBrTv=LL$dG z%nSLYq-EU=e|}#{F_Z%>rvkD9wXa3eE8D{<1@^7jwVpZW`O?#|3xaWQq%nEY63vb4 z4_j*G!Lq&_;{5gK$KOCZv_`yx8WUwY)Rh|a`Vb4Ssp`$d})UA$tUbYsRyb~vOv25s%y0;!})eJ<2JRheH3(F409Rrm;~R zZl6(VVUL2nZYmSNBxo+dXAJq;{Ov%S70ZDbHH7s5%2Ij4T5oa-m9Q-S?Ua_3aGa9w!A=VMg*kyRs0!66-asNMJ;-VDUvz0 zKWd#9AeWR0sj#tXfYe^LXw%SZpALH<^VR&BIIj(;WCD%@IbcLCBcE`LMtkevnP%_Y zk69Lt+7qMJEGRaRmdr<=kJXpVM=#XAdmeWAG4jCJ3S2N;l;oLP5K^2KKMg5f_ZcWiNYK zKr<+zKQ6<59r?xMWwsmZQ+~=Nglo%sA*6w<`|4Q8I4cmMd?B~?9>f&l{AU@~om19n z69=#}d=4ab58{LM9PAlI<2%tH))kT~Xq9yl@yDooQK;gamPNWue4)>=OOD1ND}NF8A9ry1!)%lDR!bQ#K?b#o3#Am7Gwve9|2eL~7Fkb`{tRB@S&EUR@JlB) zbp?wd2}FQG2~J~N`P%yO2nv72xg%PB71Bbr@lUakanlHq48z*6o)wYL8ac!*!}-UM((hUC`3#@RDA+ZR@1V<$y=DDAAD4TP z%INC2jBNChqH8Ss6zswt#E?sWpe?Z{v4?yyz54@&HK%3$5q=7j-u>}t`>5LHISKYg zxo#7Cs3gms%8Vtow-KK&HHr}ofz)WU3d-`NHininwtxeI;1V~89Xa!RC(v@F5w1Vu zKj2-$0R_~4*Ki)ya%U|9_#&&dzfcQS5uyl>p(w_A~v5>ipl5nJ&2)m19AO; zWtKXtEK~bHYfN%JH&##h{T{)hc=&9GT>Mi#RUG|kEM#2TU!!~x518{~95KfFh@BX7 zePl0lM|Ocu8w97&2p>+_>6jqTvH%XHN=~BBLrCZgYMEMjm<~TtZtSnZB?*SK7T~1R zC|Y@6(k>vo!n3?ul;M3x*!Ey9N&9AF>~^A*P)0e4GQz8r70jfu2uP2xW1a07*zt>* zyp<%z4_OfYP*NHu__QuD7Cl8m59cI$ievQ5V|pqgda%w!GnZQY8b!{J1X9-#xIIpF zUD_VOLk#2;reOz9P6MWHnBgtTl zIVwt#tVt%-gcsq6TN<(0Tp35 zqT)4B@h4CbV#kfJq^YCEGWPM*>I>&X;?;C^o9j=;$jqaTEdzgGi*$B^(h*L~x7|1L zaBhxqoUhZ;^Ik@2f}>|Q?3wWA^=%xZn7oao!HMHz&@MuLoeg={dN<=FXHH=eM3iG&tbW!8 zf@RrNd01yr97D3b$w%Ib;D;wN+K@WZ*3G7ImA4mtsrC^#jWP#t2$I8o!Ze#p=1ZO8 z1RvYA#57&OB|VS-Zjjy`Ao;d)k06?H8hYG2P@ZrChVmzVYBv%uG)S*Ne#vLq)R*MT zm-{NjQcXg0U?I+o@h_Ug=eMyJ1x^`vaM~xrLY$ri2&48_5F79=z2bb}qH>YuEJKJi zC$`>--|G_J!TF!KrPQ7-7%^ziM-XApV$UQCdVF=Op?Fa*V!2S)kFhdXY!p z7%QYqNzlJ2mb^Ys&rjLeIQ`ngNnAdk6c?k0XrUXQXtglEF9_?deGJHcR8K--%m&jK zFxD%FcF|`z`P5L{NNQ;HU+OMq8?7r?>sM%vh%J*Ek#lY$8~E9_MWMLWVLi6%#?<+` zQ0FBV*7`!q*or5nBZgW|)EY?Vd7gl{Q0Hx&RE5^GF{Ly^TM$|$vbsbWS-`kIpfRxY z3y~+^5>brG$zH~mXnBOudXh1YHFh{HloySsh;p&MLWfJ1nC)_4hsa7=_GuxNK{&*Y zJyR!9o!5iP+-!8OeLCrgxs7IPjCs^rt$ljL6(r^)s<_?Km?b99WBvZlUc@@cb9aFc z`zw6GHiZ6Q6tHLnu9<=h+WFVuY8r_PzsFQu_-%XOmtS>ScGC!4Ckn2_2l8+oOKvTM ztMCxZ4Y~vA(Rwe{zKn=1yJZCY*($ecZ$V5e#F_-wF29D`0AZxZ#y ze;9H&g*1iW5M|V3$tC7~HLZ z7@{YRE4EsYsN%f>Dm_~NmLfL4ol#7{kBr@3QuuLO^gl_KdhC&sT{cp(_&rM~S@MS= z`t^#mIf4^VB9bi?$#VWUx5tv~ikQx_WWAXrSmFmT0>2F(Np?jHA4ztc>eS3*N!EFc zHIzz^mh39T=1Z1h2zNBL`6WWauhA5|D+QX5aZ1Ui759;nT|H8=?q@<-;@47f$#M#D z8m-(1I_rsKIsacHS=qsi>1_Bbu=uaF^fz%f^`9}$6S(7qI0v>ku9(i+eI!{qbr*6& zGA8QJVJS#!Ca4k8{k;q!T1$&dS@??Mx&^uDcgO)3bjybL<=4rUeF1Xh5QV!%dS1uAXaufd#dXaXxJGGZIEJCQ%*gRfFCgd3kYe&> z_&&ryhA11gfHCGvpoQ{9_2lwBM1gKf3e_9!5(PK|jg`23J4kT>C2Z4RU*C^7x}$=y zMARHh%6PL9txNWpc!mrb=}&Yb@XH+@mQA)hzz3V=o}*H}1-sLPD}htcGjSu*Ed;JhG zw*4gk69$5U7@Qci?S~jA#W>*UZ@i<4VBw!EO&8v*E25Wa?5XxGc-;x^-~G0=@qC{e zR)et~Y=i$vxSMebRwP~AUxt@CA&W-KgFNv2cY2Wtzk;Cq)CyS=Nxo05)X;ruQ}{l$ zX?&krHQ%Q;pKF3n;^{X0SL5HpZva$Q_!T9kP>h2YB?k(fjO&S&ZaSEwDu^J*tlG#z z=Cg1=V{V1J&)fp{ju;lKgd)f1ILGar^%pq3F(tDf5?g{Qp_e8hK zorN3oX5*H;x$ZpN4u>|1-^;#&i_2zW#9kDZ0ry9{5z;Xcd_*Pv0+Fl*QBoCZWE11D z?2mAMx&e(_HO|ozeu{UnsrftSZ3!ceV40(DaXj5WD7$n7wVF%UFa#-0t9mGJ9SGr0 zQ@C``A^qAH)N`ILdjY=5Px41i(XEVpGb722Zc{mc9FasxmPAP+@d>ov1^jz;x^E?d zajta|?hE|F-@WA|<@6=rx@fa~5KHNDD=OqxG?80TCAXp(+=|!|D9wiJ3f#x2-EEzU z6RO|BOLceR#kw!yPQ{PeSKA-Aufe-$_t^L1-MX*f<->o&`)A*?iE+;x)9d;$6FcvG2!=b`RKJ$J=TD>P*19XA_-CxSz4YIR`J5 zolEb`Ip;ZF#M@!}A?=CQ8k|G2tRwh1_B4AYBuX!a7UGu2l~xJfU|esN*&P_YO|&<` zc1^N(+m~Cj?C0(0tr~ha&pO$D)&7lj3f@FZSZTa#R%Krs3df57g^@#ON>ruSp_D}X+`*Zef_UG-}?JwYF$?NRv?N8b_ z*mv0fZ11zbXzw3`L*k8i`XcjFY~5+mD3|!*w*s*yS7pxuKlJM9ID0$JSe7#n$1&YA znaV1ruiE(-=!@?-Ko{Ms+>a${60}y9;2Ri!?YR6y(=Q$s6_Bi{H zyNq-TIyK#%0eyk4Ku2(QzB3m(vC+BIxhjNcL+@xT6j8Z>B}6(HmqfKS{x4{NjU2o2 zj^L&C2kbre2kp!358(!9XeiY)YWZU!76+%&CjNEp3beS~5-e^BF1LiZJj(DpEWBr) zn3s4a@hmWS?n{ZHL^0mr<8>;;sAWr>l$f7*SK_;fNw^2yAG%K`_JkBs?X)07Z_~$R z!K+o!=OE63wH@!g>ioue%}u&RZn0b9j&n=hlid04yWAT0WS8vec<1NNFP&GM|8oA| zyzTtiwcP}6V?7=>tDcB{WL^v!sCRzrj(5x4Q`{8dJ?B@>pIp9+bwsY$-3e~Fn|3pt d>#*|{u4=_iuCZKb4|xBO`dG()#ld|K{~uT@B0>NF literal 0 HcmV?d00001 diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 983e47a..108e4e6 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,9 @@ workspace = true craft-ui = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true, features = ["image"] } +arboard = { workspace = true } +# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp; owner asked for jpeg and the other formats on 2026-10-21). +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -55,4 +58,3 @@ notescraft-sync-server = { path = "../../apps/notescraft-sync-server" } axum = { workspace = true } tokio = { workspace = true } ureq = { workspace = true } -image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/design/fonts/mod.rs b/crates/ui-egui/src/design/fonts/mod.rs new file mode 100644 index 0000000..9ed1ed2 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/mod.rs @@ -0,0 +1,217 @@ +//! Fonts: `Inter` for the UI, `JetBrains Mono` for code and `STIX Two Math` for math, +//! shipped in `assets/fonts` like the other Craft apps (SIL OFL 1.1, see `ATTRIBUTION.md`). +//! +//! Every other script (Indic, Thai, CJK, ...) is loaded lazily from the system fonts: [`ensure_scripts`] scans +//! text and appends the font of each script it finds, once. egui does no OpenType shaping, so conjuncts and +//! reordering marks (Bengali, Devanagari, Tamil, Thai, Khmer, ...) draw as unjoined letters. + +mod scripts; +mod system; +#[cfg(test)] +mod tests; + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use egui::{FontData, FontDefinitions, FontFamily}; + +const INTER_REGULAR: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Regular.ttf"); +const INTER_MEDIUM: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Medium.ttf"); +const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-SemiBold.ttf"); +const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../../assets/fonts/JetBrainsMono-NoLigatures.ttf"); +const STIX_MATH: &[u8] = include_bytes!("../../../../../assets/fonts/STIXTwoMath-Regular.ttf"); + +/// Fonts the system ships with Hebrew and Arabic letters, read when present: the bundled fonts have none, and a +/// line of right-to-left text would otherwise show empty boxes. +const SYSTEM_FALLBACKS: [&str; 7] = [ + "/System/Library/Fonts/SFHebrew.ttf", + "/System/Library/Fonts/SFArabic.ttf", + "C:\\Windows\\Fonts\\segoeui.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", + "/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf", +]; +/// Largest system font read, in bytes. +const MAX_FALLBACK_BYTES: u64 = 24 * 1024 * 1024; + +/// The bytes of the system fallback fonts that exist here. +fn system_fallbacks() -> Vec> { + SYSTEM_FALLBACKS + .iter() + .filter(|path| std::fs::metadata(path).is_ok_and(|meta| meta.len() <= MAX_FALLBACK_BYTES)) + .filter_map(|path| std::fs::read(path).ok()) + .collect() +} + +/// Named family for medium-weight text. +pub const MEDIUM: &str = "inter-medium"; +/// Named family for semibold text (titles, tab labels). +pub const SEMIBOLD: &str = "inter-semibold"; +/// Named family for math glyphs (`SPEC` item 30). +pub const MATH: &str = "stix-math"; + +/// The math family: STIX Two Math with the UI fonts as glyph fallbacks. +#[must_use] +pub fn math() -> FontFamily { + FontFamily::Name(MATH.into()) +} + +/// The medium-weight family. +#[must_use] +pub fn medium() -> FontFamily { + FontFamily::Name(MEDIUM.into()) +} + +/// The semibold family. +#[must_use] +pub fn semibold() -> FontFamily { + FontFamily::Name(SEMIBOLD.into()) +} + +/// Installs `Inter` as the proportional font, `JetBrains Mono` as the monospace font and the two +/// named weight families. egui's bundled fonts stay behind them as glyph fallbacks. +pub fn install(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + for (name, bytes) in [("inter", INTER_REGULAR), (MEDIUM, INTER_MEDIUM), (SEMIBOLD, INTER_SEMIBOLD), ("jetbrains-mono", JETBRAINS_MONO), (MATH, STIX_MATH)] { + fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + } + let proportional = fonts.families.entry(FontFamily::Proportional).or_default(); + proportional.insert(0, "inter".to_owned()); + for (index, bytes) in system_fallbacks().into_iter().enumerate() { + let name = format!("system-fallback-{index}"); + fonts.font_data.insert(name.clone(), Arc::new(FontData::from_owned(bytes))); + if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { + chain.push(name); + } + } + // STIX Two Math is the last resort of every text family, so symbols, arrows and math operators never show boxes. + if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { + chain.push(MATH.to_owned()); + } + let monospace = fonts.families.entry(FontFamily::Monospace).or_default(); + monospace.insert(0, "jetbrains-mono".to_owned()); + monospace.push(MATH.to_owned()); + for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] { + let mut chain = vec![face.to_owned(), "inter".to_owned()]; + if let Some(base) = fonts.families.get(&FontFamily::Proportional) { + chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); + } + fonts.families.insert(FontFamily::Name(family.into()), chain); + } // Math family first, then the UI fonts as glyph fallbacks for symbols STIX lacks. + let mut math_chain = vec![MATH.to_owned(), "inter".to_owned()]; + if let Some(base) = fonts.families.get(&FontFamily::Proportional) { + math_chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); + } + math_chain.push("jetbrains-mono".to_owned()); + fonts.families.insert(FontFamily::Name(MATH.into()), math_chain); + let state = State { defs: fonts.clone(), loaded: 0, missing: 0 }; + ctx.data_mut(|data| data.insert_temp(egui::Id::new(STATE_KEY), Arc::new(Mutex::new(state)))); + ctx.set_fonts(fonts); +} + +/// Where the installed definitions live in the context. +const STATE_KEY: &str = "notescraft-font-state"; +/// Most bytes of a text scanned for scripts. +const MAX_SCAN_BYTES: usize = 64 * 1024; +/// Most characters of a text scanned for scripts. +const MAX_SCAN_CHARS: usize = 20_000; +/// Scripts seen by [`note_text`] and [`note_names`] and not yet applied by [`apply_pending`]. +static PENDING: AtomicU64 = AtomicU64::new(0); + +/// What [`install`] set and the scripts added since. +struct State { + defs: FontDefinitions, + /// Bit `i` set: `SCRIPTS[i]` has its font in `defs`. + loaded: u64, + /// Bit `i` set: no installed font for `SCRIPTS[i]`; not tried again. + missing: u64, +} + +/// Whether `code` is in a range of `script`. +fn covers(script: &scripts::Script, code: u32) -> bool { + script.ranges.iter().any(|&(lo, hi)| (lo..=hi).contains(&code)) +} + +/// The set of scripts in `text` (bit `i` is `SCRIPTS[i]`); looks at the first 64 KiB and 20 000 characters only. +fn detect(text: &str) -> u64 { + let mut found = 0_u64; + let mut bytes = 0_usize; + let mut last = 0_usize; + for c in text.chars().take(MAX_SCAN_CHARS) { + bytes += c.len_utf8(); + if bytes > MAX_SCAN_BYTES { + break; + } + let code = u32::from(c); + // Runs of one script are the common case: test the previous hit first. + if code < scripts::FIRST_SCRIPT_CHAR || scripts::SCRIPTS.get(last).is_some_and(|script| covers(script, code)) { + continue; + } + if let Some(index) = scripts::SCRIPTS.iter().position(|script| covers(script, code)) { + found |= 1 << index; + last = index; + } + } + found +} + +/// Remembers the scripts of `text` for [`apply_pending`]; needs no `Context`. +pub fn note_text(text: &str) { + let found = detect(text); + if found != 0 { + PENDING.fetch_or(found, Ordering::Relaxed); + } +} + +/// Like [`note_text`] for a list of file names, each scanned in full. +pub fn note_names<'a>(names: impl IntoIterator) { + for name in names { + note_text(name); + } +} + +/// Loads the fonts [`note_text`] asked for; call once per frame, cheap when nothing is pending. `true` when fonts changed. +pub fn apply_pending(ctx: &egui::Context) -> bool { + let wanted = PENDING.swap(0, Ordering::Relaxed); + wanted != 0 && add_scripts(ctx, wanted, system::file_size, |path| std::fs::read(path).ok()) +} + +/// Loads the system font of every script in `text` that has none yet, with one `set_fonts` call. +/// `true` when fonts changed (they apply from the next frame, so a repaint is requested). +pub fn ensure_scripts(ctx: &egui::Context, text: &str) -> bool { + let wanted = detect(text); + wanted != 0 && add_scripts(ctx, wanted, system::file_size, |path| std::fs::read(path).ok()) +} + +/// [`ensure_scripts`] over injected file access. +fn add_scripts(ctx: &egui::Context, wanted: u64, size_of: impl Fn(&str) -> Option, read: impl Fn(&str) -> Option>) -> bool { + let Some(state) = ctx.data(|data| data.get_temp::>>(egui::Id::new(STATE_KEY))) else { return false }; + let Ok(mut state) = state.lock() else { return false }; + let todo = wanted & !(state.loaded | state.missing); + let mut changed = false; + for (bit, script) in scripts::SCRIPTS.iter().enumerate().filter(|(bit, _)| todo & (1 << bit) != 0) { + let font = system::pick(script, &size_of).and_then(|pick| read(&pick.path).map(|bytes| (pick, bytes))); + let Some((pick, bytes)) = font else { + state.missing |= 1 << bit; + log::warn!("no system font for the {} script; its text shows empty boxes", script.name); + continue; + }; + state.loaded |= 1 << bit; + let name = format!("script-{}", script.name); + let mut data = FontData::from_owned(bytes); + data.index = pick.index; + state.defs.font_data.insert(name.clone(), Arc::new(data)); + for chain in state.defs.families.values_mut() { + // Script fonts go before the math fallback, which stays last (it leads only the math family). + let at = chain.iter().rposition(|n| n == MATH).filter(|at| *at > 0).unwrap_or(chain.len()); + chain.insert(at, name.clone()); + } + changed = true; + } + if changed { + ctx.set_fonts(state.defs.clone()); + ctx.request_repaint(); + } + changed +} diff --git a/crates/ui-egui/src/design/fonts/scripts.rs b/crates/ui-egui/src/design/fonts/scripts.rs new file mode 100644 index 0000000..9005cf6 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/scripts.rs @@ -0,0 +1,76 @@ +//! The script table: Unicode ranges that need a system font, and where each OS keeps one. + +/// One face to try: file path and the face index inside a `.ttc` (`0` for a plain font). +pub(super) type Face = (&'static str, u32); + +/// A writing system the bundled fonts cannot draw. +pub(super) struct Script { + /// Short name, used for the egui font name and the log line. + pub(super) name: &'static str, + /// Inclusive code point ranges that trigger the script. + pub(super) ranges: &'static [(u32, u32)], + /// macOS and Windows files, best first. + pub(super) files: &'static [Face], + /// Linux Noto file names, probed in every [`NOTO_DIRS`] directory. + pub(super) noto: &'static [Face], +} + +/// Directories the Linux distributions put Noto fonts in. +pub(super) const NOTO_DIRS: [&str; 6] = [ + "/usr/share/fonts/truetype/noto/", + "/usr/share/fonts/noto/", + "/usr/share/fonts/opentype/noto/", + "/usr/share/fonts/noto-cjk/", + "/usr/share/fonts/google-noto/", + "/usr/share/fonts/google-noto-cjk/", +]; + +/// Face indexes of `NotoSansCJK-Regular.ttc`. +const CJK_JP: u32 = 0; +const CJK_KR: u32 = 1; +const CJK_SC: u32 = 2; + +/// The first code point any table range starts at: smaller characters never need a lookup. +pub(super) const FIRST_SCRIPT_CHAR: u32 = 0x0530; + +macro_rules! face { + ($dir:expr, $name:literal) => { + (concat!($dir, $name), 0) + }; + ($dir:expr, $name:literal, $index:expr) => { + (concat!($dir, $name), $index) + }; +} + +/// Every lazily loaded script; the position is the bit in the loaded set (at most 64 entries). +#[rustfmt::skip] +pub(super) const SCRIPTS: &[Script] = &[ + Script { name: "armenian", ranges: &[(0x0530, 0x058F), (0xFB13, 0xFB17)], files: &[face!("/System/Library/Fonts/", "SFArmenian.ttf"), face!("/System/Library/Fonts/Supplemental/", "Mshtakan.ttc"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansArmenian-Regular.ttf", 0)] }, + Script { name: "georgian", ranges: &[(0x10A0, 0x10FF), (0x1C90, 0x1CBF), (0x2D00, 0x2D2F)], files: &[face!("/System/Library/Fonts/", "SFGeorgian.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansGeorgian-Regular.ttf", 0)] }, + Script { name: "devanagari", ranges: &[(0x0900, 0x097F), (0xA8E0, 0xA8FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Devanagari Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "DevanagariMT.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansDevanagari-Regular.ttf", 0)] }, + Script { name: "bengali", ranges: &[(0x0980, 0x09FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Bangla Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorBangla.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansBengali-Regular.ttf", 0)] }, + Script { name: "gurmukhi", ranges: &[(0x0A00, 0x0A7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Gurmukhi Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Gurmukhi MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansGurmukhi-Regular.ttf", 0)] }, + Script { name: "gujarati", ranges: &[(0x0A80, 0x0AFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Gujarati Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorGujarati.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansGujarati-Regular.ttf", 0)] }, + Script { name: "oriya", ranges: &[(0x0B00, 0x0B7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Oriya Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansOriya.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansOriya-Regular.ttf", 0)] }, + Script { name: "tamil", ranges: &[(0x0B80, 0x0BFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Tamil Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Tamil MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansTamil-Regular.ttf", 0)] }, + Script { name: "telugu", ranges: &[(0x0C00, 0x0C7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Telugu Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorTelugu.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansTelugu-Regular.ttf", 0)] }, + Script { name: "kannada", ranges: &[(0x0C80, 0x0CFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Kannada Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansKannada.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansKannada-Regular.ttf", 0)] }, + Script { name: "malayalam", ranges: &[(0x0D00, 0x0D7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Malayalam Sangam MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansMalayalam-Regular.ttf", 0)] }, + Script { name: "sinhala", ranges: &[(0x0D80, 0x0DFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Sinhala Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Sinhala MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansSinhala-Regular.ttf", 0)] }, + Script { name: "thai", ranges: &[(0x0E00, 0x0E7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Thonburi.ttc"), face!("/System/Library/Fonts/Supplemental/", "Ayuthaya.ttf"), face!("/System/Library/Fonts/Supplemental/", "Sathu.ttf"), face!("/System/Library/Fonts/", "ThonburiUI.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf"), face!("C:\\Windows\\Fonts\\", "tahoma.ttf")], noto: &[("NotoSansThai-Regular.ttf", 0)] }, + Script { name: "lao", ranges: &[(0x0E80, 0x0EFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Lao Sangam MN.ttf"), face!("/System/Library/Fonts/Supplemental/", "Lao MN.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf")], noto: &[("NotoSansLao-Regular.ttf", 0)] }, + Script { name: "tibetan", ranges: &[(0x0F00, 0x0FFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Kailasa.ttc"), face!("C:\\Windows\\Fonts\\", "himalaya.ttf")], noto: &[("NotoSansTibetan-Regular.ttf", 0)] }, + Script { name: "myanmar", ranges: &[(0x1000, 0x109F), (0xA9E0, 0xA9FF), (0xAA60, 0xAA7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Myanmar Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansMyanmar.ttc"), face!("C:\\Windows\\Fonts\\", "mmrtext.ttf")], noto: &[("NotoSansMyanmar-Regular.ttf", 0)] }, + Script { name: "khmer", ranges: &[(0x1780, 0x17FF), (0x19E0, 0x19FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Khmer Sangam MN.ttf"), face!("/System/Library/Fonts/Supplemental/", "Khmer MN.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf")], noto: &[("NotoSansKhmer-Regular.ttf", 0)] }, + Script { name: "ethiopic", ranges: &[(0x1200, 0x139F), (0x2D80, 0x2DDF), (0xAB00, 0xAB2F)], files: &[face!("/System/Library/Fonts/Supplemental/", "KefaIII.ttf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansEthiopic-Regular.ttf", 0)] }, + Script { name: "syriac", ranges: &[(0x0700, 0x074F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansSyriac-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansSyriac-Regular.ttf", 0)] }, + Script { name: "thaana", ranges: &[(0x0780, 0x07BF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansThaana-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "mvboli.ttf")], noto: &[("NotoSansThaana-Regular.ttf", 0)] }, + Script { name: "nko", ranges: &[(0x07C0, 0x07FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansNKo-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansNKo-Regular.ttf", 0)] }, + Script { name: "cherokee", ranges: &[(0x13A0, 0x13FF), (0xAB70, 0xABBF)], files: &[face!("/System/Library/Fonts/Supplemental/", "PlantagenetCherokee.ttf"), face!("C:\\Windows\\Fonts\\", "plantc.ttf")], noto: &[("NotoSansCherokee-Regular.ttf", 0)] }, + Script { name: "canadian", ranges: &[(0x1400, 0x167F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansCanadianAboriginal-Regular.otf"), face!("C:\\Windows\\Fonts\\", "euphemia.ttf")], noto: &[("NotoSansCanadianAboriginal-Regular.ttf", 0)] }, + Script { name: "mongolian", ranges: &[(0x1800, 0x18AF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansMongolian-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "monbaiti.ttf")], noto: &[("NotoSansMongolian-Regular.ttf", 0)] }, + Script { name: "tifinagh", ranges: &[(0x2D30, 0x2D7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansTifinagh-Regular.otf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansTifinagh-Regular.ttf", 0)] }, + Script { name: "kana", ranges: &[(0x3040, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F)], files: &[face!("/System/Library/Fonts/", "ใƒ’ใƒฉใ‚ฎใƒŽ่ง’ใ‚ดใ‚ทใƒƒใ‚ฏ W3.ttc"), face!("C:\\Windows\\Fonts\\", "YuGothM.ttc"), face!("C:\\Windows\\Fonts\\", "meiryo.ttc"), face!("C:\\Windows\\Fonts\\", "msgothic.ttc")], noto: &[("NotoSansCJK-Regular.ttc", CJK_JP), ("NotoSansCJKjp-Regular.otf", 0)] }, + Script { name: "hangul", ranges: &[(0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "AppleGothic.ttf"), face!("/System/Library/Fonts/", "AppleSDGothicNeo.ttc"), face!("C:\\Windows\\Fonts\\", "malgun.ttf")], noto: &[("NotoSansCJK-Regular.ttc", CJK_KR), ("NotoSansCJKkr-Regular.otf", 0)] }, + Script { name: "han", ranges: &[(0x2E80, 0x2FDF), (0x3000, 0x303F), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFE30, 0xFE4F), (0xFF00, 0xFF65), (0xFFA0, 0xFFEF), (0x20000, 0x2FA1F)], files: &[face!("/System/Library/Fonts/", "PingFang.ttc"), face!("/System/Library/Fonts/", "Hiragino Sans GB.ttc"), face!("/System/Library/Fonts/", "STHeiti Medium.ttc"), face!("C:\\Windows\\Fonts\\", "msyh.ttc"), face!("C:\\Windows\\Fonts\\", "msjh.ttc"), face!("C:\\Windows\\Fonts\\", "simsun.ttc")], noto: &[("NotoSansCJK-Regular.ttc", CJK_SC), ("NotoSansCJKsc-Regular.otf", 0)] }, +]; diff --git a/crates/ui-egui/src/design/fonts/system.rs b/crates/ui-egui/src/design/fonts/system.rs new file mode 100644 index 0000000..752d14a --- /dev/null +++ b/crates/ui-egui/src/design/fonts/system.rs @@ -0,0 +1,28 @@ +//! Finding and reading the system font of a script; file access is injected so tests need no fonts. + +use super::scripts::{NOTO_DIRS, Script}; + +/// Largest system font file read, in bytes. +pub(super) const MAX_FONT_BYTES: u64 = 64 * 1024 * 1024; + +/// A system font chosen for a script. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Pick { + /// File path. + pub(super) path: String, + /// Face index inside the file. + pub(super) index: u32, +} + +/// The first candidate of `script` whose `size_of` is within [`MAX_FONT_BYTES`]; `size_of` is the file size or `None` when missing. +pub(super) fn pick(script: &Script, size_of: impl Fn(&str) -> Option) -> Option { + let fits = |path: &str| size_of(path).is_some_and(|len| len <= MAX_FONT_BYTES); + let direct = script.files.iter().map(|&(path, index)| (path.to_owned(), index)); + let noto = script.noto.iter().flat_map(|&(file, index)| NOTO_DIRS.iter().map(move |dir| (format!("{dir}{file}"), index))); + direct.chain(noto).find(|(path, _)| fits(path)).map(|(path, index)| Pick { path, index }) +} + +/// The size of the file at `path`, when it exists. +pub(super) fn file_size(path: &str) -> Option { + std::fs::metadata(path).ok().map(|meta| meta.len()) +} diff --git a/crates/ui-egui/src/design/fonts/tests.rs b/crates/ui-egui/src/design/fonts/tests.rs new file mode 100644 index 0000000..6829f71 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/tests.rs @@ -0,0 +1,160 @@ +//! Script detection, font choice over a fake file list, and idempotence. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, clippy::pedantic)] + +use super::scripts::SCRIPTS; +use super::*; + +fn bit(name: &str) -> u64 { + SCRIPTS.iter().position(|script| script.name == name).map_or(0, |i| 1 << i) +} + +#[test] +fn detects_each_script() { + assert_eq!(detect("เฆฌเฆพเฆ‚เฆฒเฆพ"), bit("bengali")); + assert_eq!(detect("เคนเคฟเคจเฅเคฆเฅ€"), bit("devanagari")); + assert_eq!(detect("เฎคเฎฎเฎฟเฎดเฏ"), bit("tamil")); + assert_eq!(detect("เธ เธฒเธฉเธฒเน„เธ—เธข"), bit("thai")); + assert_eq!(detect("ไธญๆ–‡"), bit("han")); + assert_eq!(detect("ใฒใ‚‰ใŒใช"), bit("kana")); + assert_eq!(detect("ํ•œ๊ตญ์–ด"), bit("hangul")); + assert_eq!(detect("\u{20000}"), bit("han")); +} + +#[test] +fn bundled_and_startup_scripts_need_nothing() { + assert_eq!(detect("Hello ะŸั€ะธะฒะตั‚ ฮ“ฮตฮนฮฌ"), 0); + assert_eq!(detect("ืขื‘ืจื™ืช ุงู„ุนุฑุจูŠุฉ"), 0); + assert_eq!(detect("๐Ÿ˜€ โœ“"), 0); + assert_eq!(detect(""), 0); +} + +#[test] +fn mixed_text_sets_every_script() { + assert_eq!(detect("a เฆฌเฆพเฆ‚เฆฒเฆพ b ๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆ ํ•œ"), bit("bengali") | bit("han") | bit("kana") | bit("hangul")); +} + +#[test] +fn scan_stops_at_the_cap() { + let filler = "a".repeat(MAX_SCAN_CHARS - 1); + assert_eq!(detect(&format!("{filler}เฆฌ")), bit("bengali")); + assert_eq!(detect(&format!("{filler}aเฆฌ")), 0); + let huge = "a".repeat(10 * MAX_SCAN_BYTES); + assert_eq!(detect(&format!("{huge}เฆฌ")), 0); + let wide = "ๆผข".repeat(MAX_SCAN_BYTES); + assert_eq!(detect(&wide), bit("han")); +} + +#[test] +fn table_is_sound() { + assert!(SCRIPTS.len() <= 64); + for (i, a) in SCRIPTS.iter().enumerate() { + assert!(!a.files.is_empty() && !a.noto.is_empty(), "{}", a.name); + for (lo, hi) in a.ranges { + assert!(lo >= &scripts::FIRST_SCRIPT_CHAR && lo <= hi, "{}", a.name); + for b in SCRIPTS.iter().skip(i + 1) { + assert!(!b.ranges.iter().any(|(l, h)| l <= hi && lo <= h), "{} overlaps {}", a.name, b.name); + } + } + } +} + +fn script(name: &str) -> &'static scripts::Script { + SCRIPTS.iter().find(|script| script.name == name).expect("known script") +} + +#[test] +fn pick_takes_the_first_existing_candidate() { + let bengali = script("bengali"); + let first = bengali.files.first().map(|f| f.0).unwrap_or_default(); + let second = bengali.files.get(1).map(|f| f.0).unwrap_or_default(); + let both = |path: &str| [first, second].contains(&path).then_some(1000); + assert_eq!(system::pick(bengali, both).map(|p| p.path), Some(first.to_owned())); + let only_second = |path: &str| (path == second).then_some(1000); + assert_eq!(system::pick(bengali, only_second).map(|p| p.path), Some(second.to_owned())); + assert_eq!(system::pick(bengali, |_| None), None); +} + +#[test] +fn pick_skips_oversized_files_and_finds_noto_on_linux() { + let han = script("han"); + let huge = |path: &str| han.files.iter().any(|f| f.0 == path).then_some(system::MAX_FONT_BYTES + 1); + assert_eq!(system::pick(han, huge), None); + let linux = |path: &str| (path == "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc").then_some(1000); + let pick = system::pick(han, linux).expect("noto cjk"); + assert_eq!(pick.index, 2); + let hangul = system::pick(script("hangul"), linux).expect("noto cjk"); + assert_eq!(hangul.index, 1); +} + +fn installed() -> egui::Context { + let ctx = egui::Context::default(); + install(&ctx); + ctx +} + +fn font_count(ctx: &egui::Context) -> usize { + let state = ctx.data(|d| d.get_temp::>>(egui::Id::new(STATE_KEY))); + state.and_then(|s| s.lock().ok().map(|s| s.defs.font_data.len())).unwrap_or_default() +} + +#[test] +fn second_call_is_a_no_op() { + let ctx = installed(); + let before = font_count(&ctx); + let wanted = bit("bengali") | bit("han"); + let size = |_: &str| Some(10); + let read = |_: &str| Some(vec![0_u8; 4]); + assert!(add_scripts(&ctx, wanted, size, read)); + assert_eq!(font_count(&ctx), before + 2); + assert!(!add_scripts(&ctx, wanted, size, read)); + assert_eq!(font_count(&ctx), before + 2); +} + +#[test] +fn missing_script_is_remembered() { + let ctx = installed(); + let before = font_count(&ctx); + let wanted = bit("thai"); + assert!(!add_scripts(&ctx, wanted, |_| None, |_| None)); + assert!(!add_scripts(&ctx, wanted, |_| Some(10), |_| Some(vec![0_u8; 4]))); + assert_eq!(font_count(&ctx), before); +} + +#[test] +fn without_install_nothing_happens() { + assert!(!ensure_scripts(&egui::Context::default(), "เฆฌเฆพเฆ‚เฆฒเฆพ")); +} + +/// Whether the family chain of `family` resolves `ch` to a real glyph. +fn resolves(ctx: &egui::Context, family: FontFamily, ch: char) -> bool { + ctx.fonts_mut(|fonts| fonts.has_glyph(&egui::FontId::new(14.0, family), ch)) +} + +#[test] +fn symbols_never_show_boxes_in_any_text_family() { + let ctx = egui::Context::default(); + install(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + for family in [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold()] { + for ch in ['\u{2A33}', '\u{2192}', '\u{2211}', '\u{221E}'] { + assert!(resolves(&ctx, family.clone(), ch), "{family:?} lacks U+{:04X}", u32::from(ch)); + } + } +} + +#[test] +fn math_stays_last_when_a_script_font_is_added() { + let ctx = egui::Context::default(); + install(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let bit = bit("bengali"); + assert!(add_scripts(&ctx, bit, |_| Some(1), |_| Some(JETBRAINS_MONO.to_vec()))); + let state = ctx.data(|data| data.get_temp::>>(egui::Id::new(STATE_KEY))).expect("state"); + let state = state.lock().expect("lock"); + for family in [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold()] { + let chain = state.defs.families.get(&family).expect("chain"); + assert_eq!(chain.last().map(String::as_str), Some(MATH), "{family:?}"); + assert!(chain.iter().any(|n| n == "script-bengali")); + } +} diff --git a/crates/ui-egui/src/shell/attachment_drop/big.rs b/crates/ui-egui/src/shell/attachment_drop/big.rs new file mode 100644 index 0000000..0a01b73 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/big.rs @@ -0,0 +1,40 @@ +//! Pictures larger than the texture limit of the device: read their size from the header, and shrink them off +//! the paint path before they become a texture (a texture above the limit panics inside egui). + +use std::io::Cursor; + +use egui::ColorImage; +use image::imageops::FilterType; + +/// Most pixels a picture may hold to be shrunk; a bigger claim is refused before any decoding. +pub(super) const MAX_PIXELS: u64 = 100_000_000; + +/// The pixel size of a picture, from the header of its bytes alone. +pub(super) fn header_size(bytes: &[u8]) -> Option<[u32; 2]> { + let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + let (width, height) = reader.into_dimensions().ok()?; + Some([width, height]) +} + +/// Whether a picture of this size cannot be a texture when the limit is `max_side`. +pub(super) fn exceeds(size: [u32; 2], max_side: u32) -> bool { + size[0].max(size[1]) > max_side +} + +/// Whether a picture of this size is too big to decode at all. +pub(super) fn too_many_pixels(size: [u32; 2]) -> bool { + u64::from(size[0]).saturating_mul(u64::from(size[1])) > MAX_PIXELS +} + +#[expect(clippy::cast_precision_loss, reason = "a size in points; pixel counts above 2^24 do not need exactness")] +/// Decodes `bytes` and shrinks the picture, keeping its aspect ratio, until both sides fit `max_side`. +pub(super) fn shrunk(bytes: &[u8], max_side: u32) -> Option { + let decoded = image::load_from_memory(bytes).ok()?; + let original = egui::vec2(decoded.width() as f32, decoded.height() as f32); + let fitted = decoded.resize(max_side.max(1), max_side.max(1), FilterType::Triangle).to_rgba8(); + let size = [usize::try_from(fitted.width()).ok()?, usize::try_from(fitted.height()).ok()?]; + let mut shrunk = ColorImage::from_rgba_unmultiplied(size, fitted.as_raw()); + // The picture keeps the size of the original in the layout; only its pixels are fewer. + shrunk.source_size = original; + Some(shrunk) +} diff --git a/crates/ui-egui/src/shell/attachment_drop/clipboard.rs b/crates/ui-egui/src/shell/attachment_drop/clipboard.rs new file mode 100644 index 0000000..d4533f9 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/clipboard.rs @@ -0,0 +1,241 @@ +//! The clipboard of the operating system: the image or the files a paste brings in (SPEC 9). +//! +//! The window layer turns a paste into a text paste and eats the key when the +//! clipboard holds no text, so an image or a copied file never reaches the app +//! as text. The clipboard is therefore read once per paste, behind the +//! [`ClipboardSource`] trait, so the tests can hand the app an image, a file +//! list or nothing without a real clipboard. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender}; + +use image::ImageEncoder; +use serde_json::json; + +use super::{Request, encoding, extension, is_image_extension, path_request}; +use crate::text::paste as strings; + +/// The largest image side the paste path accepts, in pixels. +pub const MAX_IMAGE_SIDE: usize = 16_384; + +/// One clipboard image: the pixels, red, green, blue and alpha, four bytes each. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardImage { + /// The width in pixels. + pub width: usize, + /// The height in pixels. + pub height: usize, + /// The pixels, four bytes each. + pub bytes: Vec, +} + +/// What the clipboard holds. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Clipboard { + /// Nothing to paste: the clipboard holds text, or is empty. + #[default] + Empty, + /// An image: a screenshot, or a picture copied from another app. + Image(ClipboardImage), + /// Files copied in the file manager. + Files(Vec), +} + +/// Reads the clipboard once. +pub trait ClipboardSource: Send + Sync { + /// Reads the clipboard. + /// + /// # Errors + /// + /// The error text when the clipboard could not be read at all; the caller + /// shows it as a toast. + fn read(&self) -> Result; +} + +/// The clipboard of the operating system. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClipboard; + +impl ClipboardSource for SystemClipboard { + fn read(&self) -> Result { + let mut clipboard = arboard::Clipboard::new().map_err(|_| strings::clipboard_unreadable().to_owned())?; + let files = clipboard.get().file_list().unwrap_or_default(); + if !files.is_empty() { + return Ok(Clipboard::Files(files)); + } + match clipboard.get().image() { + Ok(image) => Ok(Clipboard::Image(ClipboardImage { width: image.width, height: image.height, bytes: image.bytes.into_owned() })), + Err(_) => Ok(Clipboard::Empty), + } + } +} + +/// The answer of one clipboard read: what it holds, or why it could not be read. +pub type Answer = Result; + +/// Starts the read of the clipboard on a worker thread. +/// +/// The read stays off the paint path: the platform hands a screenshot over as +/// a picture that costs tens of milliseconds to decode. The answer comes +/// through the channel, one or more frames later. +#[must_use] +pub fn spawn(source: Arc) -> Receiver { + let (sender, answer): (Sender, Receiver) = std::sync::mpsc::channel(); + let worker = sender.clone(); + let fallback = Arc::clone(&source); + let spawned = std::thread::Builder::new().name("notescraft-clipboard".into()).spawn(move || { + let _ = worker.send(source.read()); + }); + if spawned.is_err() { + // The thread could not start: the answer comes from here. + let _ = sender.send(fallback.read()); + } + answer +} + +/// What one clipboard read produced. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Pasted { + /// The requests to run: the files to copy into the vault and the links to insert. + pub requests: Vec, + /// The names of the files `NotesCraft` cannot attach. + pub unsupported: Vec, +} + +/// The requests for one clipboard read. +/// +/// A file inside the vault is linked, never copied; a file outside is copied +/// into the attachment folder by the engine. An image becomes a PNG the +/// engine saves under the `Pasted image` name. +/// +/// # Errors +/// +/// The error text when the clipboard holds an image that cannot be attached; +/// the caller shows it as a toast. +pub fn requests(content: Clipboard, vault: &Path, max_bytes: usize) -> Result { + match content { + Clipboard::Empty => Ok(Pasted::default()), + Clipboard::Image(image) => { + let png = encode_png(&image, max_bytes)?; + let request = Request::Command { command_id: "attachment.save", params: json!({ "ext": "png", "data_base64": encoding::encode(&png) }) }; + Ok(Pasted { requests: vec![request], unsupported: Vec::new() }) + } + Clipboard::Files(files) => Ok(file_requests(&files, vault)), + } +} + +/// The request for one pasted file: a link when the file lies inside the +/// vault, a command that copies it in when it does not. +/// +/// # Errors +/// +/// The error text when the file cannot be attached: a type the engine does +/// not accept, a path that cannot be linked or a path that is not valid UTF-8. +pub fn file_request(file: &Path, vault: &Path) -> Result { + let Some(extension) = extension(file) else { + return Err(strings::unsupported_type().to_owned()); + }; + match place(vault, file) { + Place::Inside(relative) => Ok(Request::Link { text: link_text(&relative, &extension), name: file_name(file) }), + Place::Unlinkable => Err(strings::invalid_path().to_owned()), + Place::Outside => path_request(file), + } +} + +/// The requests for the files on the clipboard. +fn file_requests(files: &[PathBuf], vault: &Path) -> Pasted { + let mut pasted = Pasted::default(); + for file in files { + match file_request(file, vault) { + Ok(request) => pasted.requests.push(request), + Err(_) => pasted.unsupported.push(file_name(file)), + } + } + pasted +} + +/// Where a pasted file sits relative to the vault. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Place { + /// Inside the vault, with the vault-relative path to link. + Inside(String), + /// Inside the vault, but the path cannot be linked. + Unlinkable, + /// Outside the vault: the engine copies the file in. + Outside, +} + +/// The place of a file relative to the vault folder. +fn place(root: &Path, path: &Path) -> Place { + if let Some(relative) = relative_of(root, path) { + return Place::Inside(relative); + } + if path.strip_prefix(root).is_ok() { + return Place::Unlinkable; + } + // The vault root is canonical and the clipboard path is not: a vault + // under `/tmp` is opened as `/private/tmp`. The second try resolves the + // path before it compares. + match path.canonicalize().ok().and_then(|real| relative_of(root, &real)) { + Some(relative) => Place::Inside(relative), + None => Place::Outside, + } +} + +/// The parts of a path under `root`, when it lies there and can be linked. +fn relative_of(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + let mut parts = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { return None }; + let text = part.to_str()?; + if text.is_empty() || text.contains(['\n', '\r', '\0']) { + return None; + } + parts.push(text); + } + if parts.is_empty() { + return None; + } + Some(parts.join("/")) +} + +/// The link to a file inside the vault: an embed for an image, a wikilink for the rest. +fn link_text(relative: &str, extension: &str) -> String { + if is_image_extension(extension) { format!("![[{relative}]]") } else { format!("[[{relative}]]") } +} + +/// The name of a file, for the toast. +fn file_name(file: &Path) -> String { + file.file_name().map(|part| part.to_string_lossy().into_owned()).unwrap_or_default() +} + +/// The PNG bytes of a clipboard image. +/// +/// # Errors +/// +/// The error text when the image is over the cap, over the largest side, or +/// does not hold the pixels its size promises. +fn encode_png(image: &ClipboardImage, max_bytes: usize) -> Result, String> { + if image.width == 0 || image.height == 0 || image.width > MAX_IMAGE_SIDE || image.height > MAX_IMAGE_SIDE { + return Err(strings::over_limit(&max_bytes.to_string())); + } + let pixels = image.width.checked_mul(image.height).and_then(|count| count.checked_mul(4)).ok_or_else(|| strings::over_limit(&max_bytes.to_string()))?; + if pixels > max_bytes { + return Err(strings::over_limit(&max_bytes.to_string())); + } + if image.bytes.len() != pixels { + return Err(strings::image_invalid().to_owned()); + } + let width = u32::try_from(image.width).map_err(|_| strings::image_invalid().to_owned())?; + let height = u32::try_from(image.height).map_err(|_| strings::image_invalid().to_owned())?; + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(&image.bytes, width, height, image::ExtendedColorType::Rgba8) + .map_err(|_| strings::image_invalid().to_owned())?; + Ok(png) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs b/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs new file mode 100644 index 0000000..7889381 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs @@ -0,0 +1,373 @@ +//! The clipboard of a paste: an image, a file list, a clipboard that holds nothing. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use egui::{Context, Event, Key, Modifiers, RawInput}; + +use super::*; +use crate::shell::attachment_drop::{Request, State}; +use crate::text::paste as strings; + +/// A clipboard the test hands to the app. +#[derive(Debug)] +struct FakeClipboard { + /// What the clipboard holds. + content: Clipboard, + /// The error the read fails with, when the clipboard cannot be read. + failure: Option, + /// How many times the clipboard was read. + reads: Arc, +} + +impl FakeClipboard { + /// A clipboard holding `content`. + fn new(content: Clipboard) -> Self { + Self { content, failure: None, reads: Arc::new(AtomicUsize::new(0)) } + } + + /// A clipboard that cannot be read. + fn failing() -> Self { + Self { content: Clipboard::Empty, failure: Some("the clipboard could not be read".to_owned()), reads: Arc::new(AtomicUsize::new(0)) } + } + + /// The counter the reads are counted in. + fn counter(&self) -> Arc { + Arc::clone(&self.reads) + } +} + +impl ClipboardSource for FakeClipboard { + fn read(&self) -> Result { + self.reads.fetch_add(1, Ordering::Relaxed); + match &self.failure { + Some(error) => Err(error.clone()), + None => Ok(self.content.clone()), + } + } +} + +/// The vault folder the tests pretend to have. +fn vault() -> PathBuf { + PathBuf::from("/tmp/notescraft-paste-vault") +} + +/// A clipboard image of `width` x `height` pixels. +fn image(width: usize, height: usize) -> ClipboardImage { + let mut bytes = Vec::with_capacity(width * height * 4); + for _ in 0..width * height { + bytes.extend_from_slice(&[10, 20, 30, 255]); + } + ClipboardImage { width, height, bytes } +} + +/// Where a paste lands. +#[derive(Debug, Clone, Copy)] +struct Target { + /// The note is in Source mode. + source: bool, + /// The editor has the focus. + focused: bool, + /// The vault is read-only. + read_only: bool, +} + +/// The target of a paste in a writable note. +fn note() -> Target { + Target { source: true, focused: true, read_only: false } +} + +/// One frame with one `V` key event, and the requests it produced. +fn key_frame(state: &mut State, target: Target, pressed: bool, modifiers: Modifiers) -> Vec { + let ctx = Context::default(); + let input = RawInput { events: vec![Event::Key { key: Key::V, physical_key: None, pressed, repeat: false, modifiers }], ..Default::default() }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), target.source, target.focused, target.read_only, &vault())); + output.textures_delta.clear(); + requests +} + +/// One frame with the paste shortcut: it asks the clipboard. +fn paste(state: &mut State, target: Target) -> Vec { + key_frame(state, target, true, Modifiers::COMMAND) +} + +/// One frame with the `V` key released without a press: the trace a paste +/// leaves when the window layer ate the key. +fn swallowed(state: &mut State, target: Target) -> Vec { + key_frame(state, target, false, Modifiers::COMMAND) +} + +/// One frame with nothing in it, and the requests it produced. +fn quiet_frame(state: &mut State, target: Target) -> Vec { + let ctx = Context::default(); + let mut requests = Vec::new(); + let mut output = ctx.run_ui(RawInput::default(), |ui| requests = state.collect(ui.ctx(), target.source, target.focused, target.read_only, &vault())); + output.textures_delta.clear(); + requests +} + +/// The frames that carry the answer of the clipboard read, and the requests it brought. +fn settle(state: &mut State, target: Target) -> Vec { + let mut requests = Vec::new(); + for _ in 0..500 { + std::thread::sleep(Duration::from_millis(1)); + requests = quiet_frame(state, target); + if !requests.is_empty() || state.reading.is_none() { + return requests; + } + } + requests +} + +/// The requests of a paste, once the answer of the clipboard arrives. +fn pasted(state: &mut State, target: Target) -> Vec { + let _ = paste(state, target); + settle(state, target) +} + +/// The bytes of a base64 string, for the tests that look at the PNG. +fn decode_base64(text: &str) -> Vec { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let mut buffer = 0_u32; + let mut bits = 0_u32; + for byte in text.bytes() { + if byte == b'=' { + break; + } + let Some(index) = ALPHABET.iter().position(|known| *known == byte) else { continue }; + buffer = (buffer << 6) | u32::from(u8::try_from(index).unwrap_or_default()); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or_default()); + buffer &= (1 << bits) - 1; + } + } + out +} + +#[test] +fn a_file_inside_the_vault_is_linked_and_never_copied() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + assert_eq!(requests.first(), Some(&Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() })); + assert!(state.error.is_none()); +} + +#[test] +fn a_file_that_is_not_an_image_is_linked_without_the_bang() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("notes/paper.pdf")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.first(), Some(&Request::Link { text: "[[notes/paper.pdf]]".to_owned(), name: "paper.pdf".to_owned() })); +} + +#[test] +fn a_file_outside_the_vault_is_copied_by_the_engine() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/paper.pdf")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + assert_eq!(*command_id, "attachment.save_path"); + assert_eq!(params.get("source_path").and_then(serde_json::Value::as_str), Some("/tmp/outside/paper.pdf")); +} + +#[test] +fn a_clipboard_image_becomes_a_png_for_the_engine() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 3))))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + assert_eq!(*command_id, "attachment.save"); + assert_eq!(params.get("ext").and_then(serde_json::Value::as_str), Some("png")); + let encoded = params.get("data_base64").and_then(serde_json::Value::as_str).unwrap_or_default(); + let bytes = decode_base64(encoded); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); + let decoded = image::load_from_memory(&bytes); + assert!(decoded.is_ok(), "the payload is not a PNG"); + let Ok(decoded) = decoded else { return }; + assert_eq!((decoded.width(), decoded.height()), (2, 3)); +} + +#[test] +fn the_engine_saves_a_pasted_image_under_the_pasted_image_name() { + let vault = crate::testsupport::temp_vault("attachment-paste-image"); + let mut config = notescraft_engine::SessionConfig::default(); + config.files.max_attachment_mib = 1; + let opened = notescraft_engine::Session::open_with(&vault, config); + assert!(opened.is_ok(), "{opened:?}"); + let Ok(mut session) = opened else { return }; + let mut registry = notescraft_engine::Registry::new(); + assert!(notescraft_engine::register_core(&mut registry).is_ok()); + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 2))))); + let requests = pasted(&mut state, note()); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + let result = registry.run(command_id, &mut session, params); + assert!(result.is_ok(), "{result:?}"); + let path = result.ok().and_then(|value| value.get("path").and_then(serde_json::Value::as_str).map(str::to_owned)); + let Some(path) = path else { return }; + assert!(path.starts_with("Attachments/Pasted image "), "{path}"); + assert!(Path::new(&path).extension().is_some_and(|extension| extension.eq_ignore_ascii_case("png")), "{path}"); + let bytes = session.vault().read(&path).unwrap_or_default(); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); +} + +#[test] +fn a_clipboard_that_holds_nothing_produces_no_request() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Empty))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.is_none()); +} + +#[test] +fn a_clipboard_that_cannot_be_read_is_reported_once() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::failing())); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert_eq!(state.error.as_deref(), Some("the clipboard could not be read")); +} + +#[test] +fn the_visual_mode_accepts_a_clipboard_paste() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let requests = pasted(&mut state, Target { source: false, focused: false, read_only: false }); + assert_eq!(requests.len(), 1); + assert!(matches!(requests.first(), Some(Request::Link { .. }))); + assert!(state.error.is_none(), "{state:?}"); +} + +#[test] +fn a_read_only_vault_refuses_the_paste() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/pic.png")])))); + let requests = pasted(&mut state, Target { source: true, focused: true, read_only: true }); + assert_eq!(requests, []); + assert_eq!(state.error.as_deref(), Some(strings::read_only())); +} + +#[test] +fn an_oversized_image_is_refused_before_it_is_encoded() { + let mut state = State { max_bytes: 8, ..Default::default() }; + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 2))))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.as_deref().is_some_and(|error| error.contains("8 byte")), "{state:?}"); +} + +#[test] +fn an_unsupported_file_is_reported_and_never_copied() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/tool.exe")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.as_deref().is_some_and(|error| error.contains("tool.exe")), "{state:?}"); +} + +#[test] +fn a_pasted_path_inside_the_vault_is_linked_and_never_copied() { + let mut state = State::default(); + let path = vault().join("Attachments/pic.png"); + let ctx = Context::default(); + let input = RawInput { events: vec![Event::Paste(path.to_string_lossy().into_owned())], ..Default::default() }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), true, true, false, &vault())); + output.textures_delta.clear(); + assert_eq!(requests, vec![Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() }]); + assert!(state.error.is_none(), "{state:?}"); +} + +#[test] +fn a_file_pasted_as_text_and_as_a_list_is_attached_once() { + let mut state = State::default(); + let path = vault().join("Attachments/pic.png"); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![path.clone()])))); + let ctx = Context::default(); + let input = RawInput { + events: vec![ + Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::Paste(path.to_string_lossy().into_owned()), + ], + ..Default::default() + }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), true, true, false, &vault())); + output.textures_delta.clear(); + requests.append(&mut settle(&mut state, note())); + assert_eq!(requests, vec![Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() }], "{requests:?}"); +} + +#[test] +fn the_visual_mode_accepts_a_paste_over_the_note() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let ctx = Context::default(); + // The note is painted: the area of the note is the drop target. + let mut output = ctx.run_ui(RawInput::default(), |ui| { + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return }; + let tokens = crate::theme::Tokens::new(theme); + state.show(ui, &tokens, egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0)), false, false); + }); + output.textures_delta.clear(); + // The pointer is over the note and another field holds the focus. + let input = RawInput { + events: vec![ + Event::PointerMoved(egui::pos2(10.0, 10.0)), + Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + ], + ..Default::default() + }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| { + ui.memory_mut(|memory| memory.request_focus(egui::Id::new("property-field"))); + requests = state.collect(ui.ctx(), false, false, false, &vault()); + }); + output.textures_delta.clear(); + requests.append(&mut settle(&mut state, Target { source: false, focused: false, read_only: false })); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(matches!(requests.first(), Some(Request::Link { .. })), "{requests:?}"); +} + +#[test] +fn the_clipboard_is_read_once_per_paste() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = pasted(&mut state, note()); + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[test] +fn a_paste_the_window_layer_swallowed_is_still_read() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = swallowed(&mut state, note()); + let _ = settle(&mut state, note()); + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[test] +fn typing_a_v_does_not_read_the_clipboard() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = key_frame(&mut state, note(), true, Modifiers::NONE); + let _ = key_frame(&mut state, note(), false, Modifiers::NONE); + assert_eq!(reads.load(Ordering::Relaxed), 0); +} diff --git a/crates/ui-egui/src/shell/attachment_drop/images.rs b/crates/ui-egui/src/shell/attachment_drop/images.rs index 6f10214..8a01251 100644 --- a/crates/ui-egui/src/shell/attachment_drop/images.rs +++ b/crates/ui-egui/src/shell/attachment_drop/images.rs @@ -1,14 +1,15 @@ //! Preloaded vault image embeds: disk reads happen before note painting. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex, mpsc}; -use crate::theme::{Tokens, color}; +use crate::lightbox::{self, Kind}; +use crate::theme::Tokens; use egui::{Context, Id, Sense, Ui, Vec2}; use notescraft_engine::Session; use notescraft_markdown::{Document, ItemKind}; -use super::{DEFAULT_MAX_BYTES, EXTENSIONS, IMAGE_EXTENSIONS, extension}; +use super::{DEFAULT_MAX_BYTES, EXTENSIONS, IMAGE_EXTENSIONS, big, extension}; /// Maximum decoded image dimension requested by an embed size hint. const MAX_DIMENSION: f32 = 8192.0; @@ -16,16 +17,35 @@ const MAX_DIMENSION: f32 = 8192.0; const MAX_IMAGES: usize = 64; const TOO_MANY: &str = "Note exceeds the {{limit}} image limit"; const TOO_LARGE: &str = "Note images exceed the {{limit}} byte limit"; -const LIGHTBOX_SELECTED: &str = "notescraft-image-lightbox-target"; -const LIGHTBOX_FRACTION: f32 = 0.85; -const LIGHTBOX_BACKDROP: f32 = 0.88; -const CLOSE_LABEL: &str = "Close preview"; -const CLOSE_SIZE: f32 = 24.0; #[derive(Debug, Clone)] struct Image { uri: String, source: Source, + /// Vault path of the file, for the lightbox. + path: String, + kind: Kind, + /// For a PDF, the page the raster shows. + page: u32, + /// Size of the file in bytes. + len: u64, + /// The SVG file, so the lightbox can rasterise it again at the size it shows it at. + svg: Option>, + /// Pixel size from the header of a bitmap, to tell a picture that exceeds the texture limit. + size: Option<[u32; 2]>, + /// The bitmap shrunk to the texture limit, once it was asked for. + shrink: Arc>, +} + +/// The shrinking of a bitmap that exceeds the texture limit of the device. +#[derive(Debug, Default)] +enum Shrink { + #[default] + Idle, + /// A worker thread decodes and shrinks the picture. + Working(mpsc::Receiver>), + /// The shrunk picture, or `None` when it cannot be read. + Done(Option>), } #[derive(Debug, Clone)] @@ -46,19 +66,62 @@ impl std::fmt::Debug for Raster { } impl Image { - fn widget(&self, ctx: &Context) -> egui::Image<'static> { - match &self.source { + /// The image to paint; `None` while a big picture is being shrunk or when it cannot be read. + fn widget(&self, ctx: &Context) -> Option> { + let limit = u32::try_from(ctx.input(|input| input.max_texture_side)).unwrap_or(u32::MAX); + if let Some(size) = self.size.filter(|size| big::exceeds(*size, limit)) { + return self.shrunk(ctx, size, limit).map(|raster| self.raster_widget(ctx, &raster)); + } + Some(match &self.source { Source::Bytes(bytes) => egui::Image::from_bytes(self.uri.clone(), bytes.clone()), - Source::Raster(raster) => { - let load = || (ctx.clone(), ctx.load_texture(&self.uri, raster.image.clone(), egui::TextureOptions::LINEAR)); - let mut cache = raster.texture.lock(); - let (owner, texture) = cache.get_or_insert_with(load); - if owner != ctx { - (*owner, *texture) = load(); + Source::Raster(raster) => self.raster_widget(ctx, raster), + }) + } + + fn raster_widget(&self, ctx: &Context, raster: &Raster) -> egui::Image<'static> { + let load = || (ctx.clone(), ctx.load_texture(&self.uri, raster.image.clone(), egui::TextureOptions::LINEAR)); + let mut cache = raster.texture.lock(); + let (owner, texture) = cache.get_or_insert_with(load); + if owner != ctx { + (*owner, *texture) = load(); + } + egui::Image::from_texture((texture.id(), raster.image.source_size)) + } + + /// The picture shrunk to `limit`, started on a worker thread by the first call. + fn shrunk(&self, ctx: &Context, size: [u32; 2], limit: u32) -> Option> { + let Ok(mut state) = self.shrink.lock() else { return None }; + if matches!(*state, Shrink::Idle) { + *state = match &self.source { + Source::Bytes(bytes) if !big::too_many_pixels(size) => { + let (sender, receiver) = mpsc::channel(); + let (bytes, repaint) = (bytes.clone(), ctx.clone()); + std::thread::spawn(move || { + // A closed channel means the note was left: nobody waits for the picture. + let _ = sender.send(big::shrunk(&bytes, limit)); + repaint.request_repaint(); + }); + Shrink::Working(receiver) } - egui::Image::from_texture((texture.id(), raster.image.source_size)) + _ => Shrink::Done(None), + }; + } + if let Shrink::Working(receiver) = &*state { + match receiver.try_recv() { + Ok(image) => *state = Shrink::Done(image.map(|image| Arc::new(Raster { image, texture: egui::mutex::Mutex::default() }))), + Err(mpsc::TryRecvError::Disconnected) => *state = Shrink::Done(None), + Err(mpsc::TryRecvError::Empty) => {} } } + match &*state { + Shrink::Done(raster) => raster.clone(), + Shrink::Idle | Shrink::Working(_) => None, + } + } + + /// Whether the picture is still being shrunk (as opposed to unreadable). + fn is_working(&self) -> bool { + self.shrink.lock().is_ok_and(|state| matches!(*state, Shrink::Working(_))) } } @@ -71,7 +134,10 @@ fn pdf_page(subpath: Option<&str>) -> u32 { /// Rebuild when the note or its attachments change; `show` performs no disk IO. #[derive(Debug, Default, Clone)] pub struct Images { - images: BTreeMap, + entries: BTreeMap, + /// Targets of the pictures, in the order of the note. + order: Vec, + root: std::path::PathBuf, } impl Images { @@ -82,11 +148,17 @@ impl Images { /// Returns a readable error when a resolved file cannot be read or the /// note's image count/total byte limit is exceeded. pub fn load(session: &Session, from: &str, doc: &Document) -> Result { - let mut result = Self::default(); + let mut result = Self { root: session.vault().root().to_path_buf(), ..Self::default() }; let mut total = 0_usize; - for item in &doc.items { - let ItemKind::Embed { target, subpath, .. } = &item.kind else { continue }; - if result.images.contains_key(target) { + let embeds = doc.items.iter().filter_map(|item| match &item.kind { + ItemKind::Embed { target, subpath, .. } => Some((target.clone(), subpath.clone())), + _ => None, + }); + // Markdown images and `` tags are loaded like `![[..]]` embeds, under the path they were written with. + let written = crate::note_view::image_targets(doc).into_iter().map(|target| (target, None)); + for (target, subpath) in embeds.chain(written) { + let (target, subpath) = (&target, &subpath); + if result.entries.contains_key(target) { continue; } let resolution = session.index().resolve(from, target); @@ -95,32 +167,51 @@ impl Images { if ext == "pdf" { // A PDF embed shows its page as an image when the system can render it; otherwise the note view // draws the file card. - if result.images.len() < MAX_IMAGES + if result.entries.len() < MAX_IMAGES && let Ok(bytes) = session.pdf_preview(path, pdf_page(subpath.as_deref())) { total = total.saturating_add(bytes.len()); let uri = format!("bytes://notescraft/embed/pdf/{}/{path}", fingerprint(&bytes)); - result.images.insert(target.clone(), Image { uri, source: Source::Bytes(bytes.into()) }); + let (path, len) = (path.to_owned(), bytes.len() as u64); + let page = pdf_page(subpath.as_deref()); + result.entries.insert( + target.clone(), + Image { uri, source: Source::Bytes(bytes.into()), path, kind: Kind::Pdf, page, len, svg: None, size: None, shrink: Arc::default() }, + ); } continue; } if matches!(ext.as_str(), "mp4" | "mov" | "mkv" | "webm" | "ogv" | "avi") { // A video shows a still from the file; the note view draws the card around it. let key = format!("poster:{path}"); - if result.images.len() < MAX_IMAGES - && !result.images.contains_key(&key) + if result.entries.len() < MAX_IMAGES + && !result.entries.contains_key(&key) && let Ok(bytes) = session.video_poster(path) { total = total.saturating_add(bytes.len()); let uri = format!("bytes://notescraft/embed/poster/{}/{path}", fingerprint(&bytes)); - result.images.insert(key, Image { uri, source: Source::Bytes(bytes.into()) }); + let (path, len) = (path.to_owned(), bytes.len() as u64); + result.entries.insert( + key, + Image { + uri, + source: Source::Bytes(bytes.into()), + path, + kind: Kind::Video, + page: 1, + len, + svg: None, + size: None, + shrink: Arc::default(), + }, + ); } continue; } if !EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == ext) { continue; } - if result.images.len() >= MAX_IMAGES { + if result.entries.len() >= MAX_IMAGES { return Err(TOO_MANY.replace("{{limit}}", &MAX_IMAGES.to_string())); } let source = session.vault().resolve(path).map_err(|error| error.to_string())?; @@ -132,6 +223,9 @@ impl Images { let bytes = session.vault().read(path).map_err(|error| error.to_string())?; total = total.saturating_add(bytes.len()); let fingerprint = fingerprint(&bytes); + let len = bytes.len() as u64; + let svg = (ext == "svg").then(|| Arc::<[u8]>::from(bytes.as_slice())); + let size = if ext == "svg" { None } else { big::header_size(&bytes) }; let source = if ext == "svg" { let raster = super::svg::decode(&bytes)?; total = total.saturating_add(raster.pixels.len().saturating_mul(std::mem::size_of::())); @@ -143,23 +237,41 @@ impl Images { return Err(TOO_LARGE.replace("{{limit}}", &DEFAULT_MAX_BYTES.to_string())); } let uri = format!("bytes://notescraft/embed/{fingerprint}/{path}"); - result.images.insert(target.clone(), Image { uri, source }); + result.order.push(target.clone()); + result + .entries + .insert(target.clone(), Image { uri, source, path: path.to_owned(), kind: Kind::Image, page: 1, len, svg, size, shrink: Arc::default() }); } Ok(result) } + /// Whether the picture of `target` is still being shrunk on a worker thread. + #[cfg(test)] + pub(super) fn is_working(&self, target: &str) -> bool { + self.entries.get(target).is_some_and(Image::is_working) + } + /// Whether `target` resolved to a preloaded image. #[must_use] pub fn contains(&self, target: &str) -> bool { - self.images.contains_key(target) + self.entries.contains_key(target) } /// Paints a preloaded image, applying an optional Obsidian `width[xheight]` /// hint. Returns false for missing images or invalid hints, so the caller /// can draw its ordinary embed fallback. Never accesses the filesystem. pub fn show(&self, ui: &mut Ui, target: &str, size: Option<&str>) -> bool { - let Some(image) = self.images.get(target) else { return false }; - let mut widget = image.widget(ui.ctx()).max_width(ui.available_width()); + let Some(image) = self.entries.get(target) else { return false }; + let Some(widget) = image.widget(ui.ctx()) else { + if image.is_working() { + ui.add(egui::Spinner::new()); + ui.ctx().request_repaint_after(WORKING_POLL); + } else { + failed(ui, target); + } + return true; + }; + let mut widget = widget.max_width(ui.available_width()); if image.uri.starts_with("bytes://notescraft/embed/pdf/") { // A rendered page has a transparent background: put it on paper. widget = widget.bg_fill(egui::Color32::WHITE); @@ -172,67 +284,83 @@ impl Images { }; } if widget.load_for_size(ui.ctx(), ui.available_size()).is_err() { - let text = crate::text::note_view::image_failed().replace("{{target}}", target); - ui.label(egui::RichText::new(text).small().color(ui.visuals().weak_text_color())); + failed(ui, target); return true; } let response = ui.add(widget.sense(Sense::click())).on_hover_cursor(egui::CursorIcon::ZoomIn); if response.clicked() { - ui.ctx().data_mut(|data| data.insert_temp(Id::new(LIGHTBOX_SELECTED), target.to_owned())); + lightbox::select(ui.ctx(), target, response.rect); } true } - /// Shows the clicked image in a raised, rounded lightbox. Escape, its close - /// control, or clicking outside closes it. Returns whether it was visible. - /// Uses only the already-preloaded bytes and never accesses the filesystem. + /// Shows the clicked image, PDF page, video or file in the lightbox (`views/lightbox`): it + /// flies out of the thumbnail, and Escape, its close control, or clicking outside closes it. + /// Returns whether it was visible. Uses only the already-preloaded bytes. + #[expect(clippy::must_use_candidate, reason = "the caller may only want the lightbox drawn")] pub fn show_lightbox(&self, ctx: &Context, tokens: &Tokens) -> bool { - let selected = Id::new(LIGHTBOX_SELECTED); - let closing_id = Id::new((LIGHTBOX_SELECTED, "closing")); + let selected = Id::new(lightbox::SELECTED); let Some(target) = ctx.data(|data| data.get_temp::(selected)) else { return false }; - let Some(image) = self.images.get(&target) else { - ctx.data_mut(|data| data.remove::(selected)); - return false; + let loose = lightbox::file_path(&target); + let Some(image) = self.entries.get(&target).filter(|_| loose.is_none()) else { + let Some(path) = loose else { + lightbox::clear(ctx); + return false; + }; + let view = lightbox::View { + target: &target, + path, + kind: Kind::File, + image: None, + bytes: None, + vector: None, + len: 0, + page: 1, + root: &self.root, + siblings: &[], + }; + return lightbox::show(ctx, tokens, &view); }; - let closing = ctx.data(|data| data.get_temp::(closing_id)).unwrap_or(false); - // Closing fades the lightbox out instead of dropping it. - let presence = crate::motion::progress(ctx, "image-lightbox-visible", !closing, crate::motion::FAST, crate::motion::ease_out_cubic); - if closing && presence <= 0.0 { - ctx.data_mut(|data| { - data.remove::(selected); - data.remove::(closing_id); - }); - return false; - } - let fade = crate::motion::appear(ctx, "image-lightbox-appear", crate::motion::FAST, crate::motion::ease_out_cubic) * presence; - let size = ctx.content_rect().size() * LIGHTBOX_FRACTION; - let mut close = false; - let response = egui::Modal::new(Id::new("notescraft-image-lightbox")) - .frame(crate::context_menu::frame(tokens)) - .backdrop_color(color(tokens.theme.colors.canvas).gamma_multiply(LIGHTBOX_BACKDROP * fade)) - .show(ctx, |ui| { - ui.multiply_opacity(fade); - if closing { - ui.disable(); - } - ui.set_max_width(size.x); - ui.horizontal(|ui| { - ui.colored_label(color(tokens.theme.colors.text_muted), &target); - let (rect, response) = ui.allocate_exact_size(Vec2::splat(CLOSE_SIZE), Sense::click()); - crate::lottie_icon::paint_hover(ui, response.id, "x", rect, color(tokens.theme.colors.text_muted), response.hovered()); - let response = response.on_hover_text(CLOSE_LABEL); - close = response.clicked(); - }); - ui.add(image.widget(ctx).fit_to_exact_size(size).max_size(size)); - }); - if !closing && (close || response.should_close()) { - ctx.data_mut(|data| data.insert_temp(closing_id, true)); - ctx.request_repaint(); - } - true + let (bytes, vector) = match (&image.source, &image.svg) { + (Source::Bytes(bytes), _) => (Some(bytes.clone()), None), + (Source::Raster(raster), Some(svg)) => { + let size = raster.image.source_size; + ( + None, + Some(lightbox::Vector { + uri: format!("bytes://notescraft/lightbox/{}", image.uri.trim_start_matches("bytes://notescraft/embed/")), + bytes: svg.clone(), + size, + }), + ) + } + (Source::Raster(_), None) => (None, None), + }; + let view = lightbox::View { + target: &target, + path: &image.path, + kind: image.kind, + image: image.widget(ctx), + bytes, + vector, + len: image.len, + page: image.page, + root: &self.root, + siblings: &self.order, + }; + lightbox::show(ctx, tokens, &view) } } +/// How often a picture that is being shrunk is looked at again, as a repaint delay. +const WORKING_POLL: std::time::Duration = std::time::Duration::from_millis(50); + +/// The notice of a picture that cannot be read. +fn failed(ui: &mut Ui, target: &str) { + let text = crate::text::note_view::image_failed().replace("{{target}}", target); + ui.label(egui::RichText::new(text).small().color(ui.visuals().weak_text_color())); +} + fn dimensions(size: &str) -> Option<(f32, Option)> { let (width, height) = size.split_once('x').map_or((size, None), |(width, height)| (width, Some(height))); let parse = |value: &str| value.parse::().ok().filter(|value| value.is_finite() && *value > 0.0 && *value <= MAX_DIMENSION); diff --git a/crates/ui-egui/src/shell/attachment_drop/mod.rs b/crates/ui-egui/src/shell/attachment_drop/mod.rs index f864903..80b7aec 100644 --- a/crates/ui-egui/src/shell/attachment_drop/mod.rs +++ b/crates/ui-egui/src/shell/attachment_drop/mod.rs @@ -1,14 +1,21 @@ //! Attachment input collection and a paint-only editor drop affordance (SPEC 9). -use egui::{Context, Event, Rect}; +use egui::{Context, Event, Key, Rect}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError}; +use crate::text::paste as strings; + +mod big; +mod clipboard; mod encoding; mod images; mod overlay; mod svg; +pub use clipboard::{Clipboard, ClipboardImage, ClipboardSource, SystemClipboard}; pub use images::Images; /// The default attachment input cap, matching the engine's 64 MiB setting. @@ -21,78 +28,194 @@ const MAX_PATH_BYTES: usize = 16 * 1024; const IMAGE_EXTENSIONS: usize = 7; /// All file types accepted by the engine's attachment commands. const EXTENSIONS: [&str; 15] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "pdf", "mp3", "wav", "ogg", "m4a", "mp4", "webm", "mov"]; -const TOO_LARGE: &str = "Attachment exceeds the {{limit}} byte limit"; -const UNSUPPORTED: &str = "Unsupported attachment file type"; -const INVALID_PATH: &str = "Attachment path is not valid UTF-8"; -const SOURCE_HINT: &str = "Switch to Source to paste images"; -const READ_ONLY_HINT: &str = "Attachments are unavailable in a read-only vault"; -/// One engine command to dispatch after collecting input, outside painting. +/// One thing to do with a pasted or dropped file. #[derive(Debug, Clone, PartialEq)] -pub struct Request { - /// Registered attachment command id. - pub command_id: &'static str, - /// Validated transport parameters; engine remains responsible for saving. - pub params: Value, +pub enum Request { + /// Save the attachment with an engine command and insert the link it returns. + Command { + /// The registered attachment command id. + command_id: &'static str, + /// Validated transport parameters; engine remains responsible for saving. + params: Value, + }, + /// Insert a link to a file that already sits inside the vault. + Link { + /// The link text: `![[path]]` for an image, `[[path]]` for anything else. + text: String, + /// The file name, for the toast. + name: String, + }, } /// Attachment input state, shared between event collection and overlay painting. -#[derive(Debug, Clone, PartialEq)] pub struct State { /// Maximum in-memory payload, normally set from session attachment settings. pub max_bytes: usize, /// Last input failure, consumed by the app's existing dismissible toast. pub error: Option, + /// The clipboard a paste reads; the system clipboard is read by default. + clipboard: Arc, + /// The read in flight, when one is. + reading: Option>, + /// The requests already made for the paste in flight: the text form and + /// the file list of one file are attached once. + pasted: Vec, + /// Whether the `V` key was seen going down, to spot a paste the window layer swallowed. + paste_key_down: bool, target: Option, } +impl std::fmt::Debug for State { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("State").field("max_bytes", &self.max_bytes).field("error", &self.error).finish_non_exhaustive() + } +} + impl Default for State { fn default() -> Self { - Self { max_bytes: DEFAULT_MAX_BYTES, error: None, target: None } + Self { + max_bytes: DEFAULT_MAX_BYTES, + error: None, + clipboard: Arc::new(SystemClipboard), + reading: None, + pasted: Vec::new(), + paste_key_down: false, + target: None, + } } } impl State { - /// Consumes writable Source attachment events before the source widget sees + /// Sets the clipboard a paste reads; the system clipboard is read by default. + pub fn set_clipboard(&mut self, source: Arc) { + self.clipboard = source; + } + + /// Consumes writable attachment events before the source widget sees /// them. Ordinary text paste stays in the event stream. No vault IO occurs. /// Native paths go straight to the engine; virtual handles are read here, /// outside painting, then size-checked before base64 allocation. - pub fn collect(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool) -> Vec { - let over_target = ctx.input(|input| self.target.map_or(focused, |rect| input.pointer.hover_pos().map_or(focused, |pos| rect.contains(pos)))); - if !source || read_only { - let paste_target = focused || (!source && ctx.memory(|memory| memory.focused().is_none())); - let attempted = ctx.input_mut(|input| { - let mut attempted = over_target && !input.raw.dropped_files.is_empty(); - if paste_target { - input.events.retain(|event| { - let image = matches!(event, Event::Paste(text) if pasted_image_path(text).is_some()); - attempted |= image; - !image - }); - } - attempted - }); - if attempted { - self.error = Some(if read_only { READ_ONLY_HINT } else { SOURCE_HINT }.to_owned()); - } - return Vec::new(); - } + /// A paste reads the clipboard once, on a worker thread, for the image or + /// the file list the platform holds; the requests go back to the app, + /// which runs them. + pub fn collect(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path) -> Vec { + let mut requests = self.clipboard_requests(ctx, source, focused, read_only, vault); + self.pasted_text(ctx, source, focused, read_only, vault, &mut requests); + self.dropped_files(ctx, source, focused, read_only, &mut requests); + requests + } + + /// The requests of the clipboard: the answer of the read in flight, and a + /// new read when the user asks to paste. + fn clipboard_requests(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path) -> Vec { let mut requests = Vec::new(); - if focused { + if let Some(answer) = self.take_answer() { + match answer { + Ok(content) => match clipboard::requests(content, vault, self.max_bytes) { + Ok(pasted) => { + for request in pasted.requests { + self.push_request(&mut requests, request); + } + if !pasted.unsupported.is_empty() { + self.error = Some(strings::cannot_attach(&pasted.unsupported.join(", "))); + } + } + Err(error) => self.error = Some(error), + }, + Err(error) => self.error = Some(error), + } + } + if self.paste_asked(ctx) && self.note_wants_paste(ctx, source, focused) { + if read_only { + self.error = Some(strings::read_only().to_owned()); + } else if self.reading.is_none() { + self.pasted.clear(); + self.reading = Some(clipboard::spawn(Arc::clone(&self.clipboard))); + } + } + requests + } + + /// Whether the paste belongs to the note: its editor has the focus, the + /// pointer sits over it, the visual view holds the caret in a block, or no + /// other field took the focus. + fn note_wants_paste(&self, ctx: &Context, source: bool, focused: bool) -> bool { + if focused || self.pointer_over_note(ctx) { + return true; + } + if !source && crate::note_view::grip::focus(ctx).is_some() { + return true; + } + ctx.memory(|memory| memory.focused().is_none()) + } + + /// Whether the pointer sits over the note the app paints. + fn pointer_over_note(&self, ctx: &Context) -> bool { + let Some(rect) = self.target else { return false }; + ctx.input(|input| input.pointer.hover_pos().is_some_and(|pos| rect.contains(pos))) + } + + /// Adds a request, unless the same one was already made for this paste: + /// the clipboard can hold a file both as a path and as a file list. + fn push_request(&mut self, requests: &mut Vec, request: Request) { + if self.pasted.contains(&request) { + return; + } + self.pasted.push(request.clone()); + requests.push(request); + } + + /// The pasted text that is a local path: an attachment, in Source mode only. + fn pasted_text(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path, requests: &mut Vec) { + if source && !read_only { + if !focused { + return; + } ctx.input_mut(|input| { input.events.retain(|event| { let Event::Paste(text) = event else { return true }; let Some(path) = pasted_image_path(text) else { return true }; - match path_request(&path) { - Ok(request) => requests.push(request), + match clipboard::file_request(&path, vault) { + Ok(request) => self.push_request(requests, request), Err(error) => self.error = Some(error), } false }); }); + return; + } + let paste_target = focused || (!source && ctx.memory(|memory| memory.focused().is_none())); + if !paste_target { + return; + } + let mut attempted = false; + ctx.input_mut(|input| { + input.events.retain(|event| { + let image = matches!(event, Event::Paste(text) if pasted_image_path(text).is_some()); + attempted |= image; + !image + }); + }); + if attempted { + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; + self.error = Some(hint.to_owned()); + } + } + + /// The dropped files: an attachment each, in Source mode only. + fn dropped_files(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, requests: &mut Vec) { + let over_target = ctx.input(|input| self.target.map_or(focused, |rect| input.pointer.hover_pos().map_or(focused, |pos| rect.contains(pos)))); + if !source || read_only { + let attempted = ctx.input(|input| over_target && !input.raw.dropped_files.is_empty()); + if attempted { + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; + self.error = Some(hint.to_owned()); + } + return; } if !over_target { - return requests; + return; } let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); for file in dropped { @@ -105,11 +228,11 @@ impl State { continue; } let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else { - self.error = Some(INVALID_PATH.to_owned()); + self.error = Some(strings::invalid_path().to_owned()); continue; }; let Some(extension) = extension(path) else { - self.error = Some(UNSUPPORTED.to_owned()); + self.error = Some(strings::unsupported_type().to_owned()); continue; }; match file.bytes().and_then(|bytes| self.bytes_request(name, &extension, &bytes)) { @@ -117,28 +240,82 @@ impl State { Err(error) => self.error = Some(error), } } - requests + } + + /// Whether the user asked to paste in this frame. + /// + /// The window layer turns the paste shortcut into a text paste and eats + /// the key when the clipboard holds no text: the release of the `V` key + /// without a press is the trace it leaves. The shortcut is also read + /// from the key event, for the platforms that report it. + fn paste_asked(&mut self, ctx: &Context) -> bool { + let mut down = false; + let mut up = false; + let mut shortcut = false; + ctx.input(|input| { + for event in &input.events { + if let Event::Key { key: Key::V, pressed, modifiers, .. } = event { + if *pressed { + down = true; + if modifiers.command || modifiers.ctrl { + shortcut = true; + } + } else { + up = true; + } + } + } + }); + if down { + self.paste_key_down = true; + } + let swallowed = up && !self.paste_key_down; + if up { + self.paste_key_down = false; + } + shortcut || swallowed + } + + /// Takes the answer of the clipboard read in flight, when it is there. + fn take_answer(&mut self) -> Option { + let receiver = self.reading.take()?; + match receiver.try_recv() { + Ok(answer) => Some(answer), + Err(TryRecvError::Empty) => { + self.reading = Some(receiver); + None + } + // The worker died without an answer: read that as a failure. + Err(TryRecvError::Disconnected) => Some(Err(strings::clipboard_unreadable().to_owned())), + } } fn bytes_request(&self, name: &str, extension: &str, bytes: &[u8]) -> Result { let cap = self.max_bytes.min(MAX_ENCODE_BYTES); if bytes.len() > cap { - return Err(TOO_LARGE.replace("{{limit}}", &cap.to_string())); + return Err(strings::over_limit(&cap.to_string())); } - Ok(Request { command_id: "attachment.save", params: json!({ "ext": extension, "data_base64": encoding::encode(bytes), "name": name }) }) + Ok(Request::Command { command_id: "attachment.save", params: json!({ "ext": extension, "data_base64": encoding::encode(bytes), "name": name }) }) } } +/// The request that copies a file from outside the vault into it. fn path_request(path: &Path) -> Result { - let path = path.to_str().ok_or_else(|| INVALID_PATH.to_owned())?; - Ok(Request { command_id: "attachment.save_path", params: json!({ "source_path": path }) }) + let path = path.to_str().ok_or_else(|| strings::invalid_path().to_owned())?; + Ok(Request::Command { command_id: "attachment.save_path", params: json!({ "source_path": path }) }) } +/// The extension of a path, when the engine accepts that kind of file. fn extension(path: &Path) -> Option { let extension = path.extension()?.to_str()?.to_ascii_lowercase(); EXTENSIONS.contains(&extension.as_str()).then_some(extension) } +/// Whether an extension names an image. +fn is_image_extension(extension: &str) -> bool { + EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == extension) +} + fn pasted_image_path(text: &str) -> Option { let text = text.trim(); if text.len() > MAX_PATH_BYTES || text.contains(['\n', '\r', '\0']) { @@ -160,7 +337,7 @@ fn pasted_image_path(text: &str) -> Option { return None; } let ext = extension(path)?; - EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == ext).then(|| path.to_owned()) + is_image_extension(&ext).then(|| path.to_owned()) } fn decode_url_path(path: &str) -> Option { diff --git a/crates/ui-egui/src/shell/attachment_drop/overlay.rs b/crates/ui-egui/src/shell/attachment_drop/overlay.rs index 333caa2..479f583 100644 --- a/crates/ui-egui/src/shell/attachment_drop/overlay.rs +++ b/crates/ui-egui/src/shell/attachment_drop/overlay.rs @@ -2,14 +2,14 @@ use egui::{Align2, FontId, Rect, Ui, Vec2}; +use crate::text::paste as strings; use crate::theme::{Tokens, color}; -use super::{READ_ONLY_HINT, SOURCE_HINT, State}; +use super::State; const WASH_OPACITY: f32 = 0.12; const ICON_SIZE: f32 = 32.0; const LABEL_OFFSET: f32 = 30.0; -const DROP_LABEL: &str = "Drop to attach"; impl State { /// Draws a rounded accent wash over `rect` while files hover over the editor. @@ -25,10 +25,16 @@ impl State { painter.rect_filled(rect, tokens.radius_medium(), tint.gamma_multiply(WASH_OPACITY)); let icon_rect = Rect::from_center_size(rect.center() - Vec2::new(0.0, LABEL_OFFSET / 2.0), Vec2::splat(ICON_SIZE)); crate::icons::paint(ui, "image", icon_rect, tint); - painter.text(rect.center() + Vec2::new(0.0, LABEL_OFFSET), Align2::CENTER_CENTER, DROP_LABEL, FontId::proportional(crate::style::TEXT_BODY), tint); + painter.text( + rect.center() + Vec2::new(0.0, LABEL_OFFSET), + Align2::CENTER_CENTER, + strings::drop_label(), + FontId::proportional(crate::style::TEXT_BODY), + tint, + ); } if hovering && !active { - let hint = if read_only { READ_ONLY_HINT } else { SOURCE_HINT }; + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.id().with("attachment-drop-hint"), rect.center()).show(|ui| { ui.label(hint); }); diff --git a/crates/ui-egui/src/shell/attachment_drop/tests.rs b/crates/ui-egui/src/shell/attachment_drop/tests.rs index 1a94ef0..74efaf2 100644 --- a/crates/ui-egui/src/shell/attachment_drop/tests.rs +++ b/crates/ui-egui/src/shell/attachment_drop/tests.rs @@ -5,8 +5,10 @@ use std::sync::Arc; use egui::{Context, Event, RawInput}; use notescraft_engine::{Registry, Session, register_core}; +use serde_json::Value; use super::*; +use crate::text::paste as strings; #[derive(Debug)] struct MemoryFile { @@ -24,10 +26,31 @@ impl egui::DroppedFile for MemoryFile { } } +/// The vault folder the tests pretend to have. +fn vault() -> PathBuf { + PathBuf::from("/tmp/notescraft-attachment-vault") +} + +/// The command id of a request, when it runs a command. +fn command_id(request: &Request) -> Option<&'static str> { + match request { + Request::Command { command_id, .. } => Some(command_id), + Request::Link { .. } => None, + } +} + +/// The parameters of a request, when it runs a command. +fn params(request: &Request) -> Option<&Value> { + match request { + Request::Command { params, .. } => Some(params), + Request::Link { .. } => None, + } +} + fn frame(state: &mut State, input: RawInput, source: bool, focused: bool, read_only: bool) -> Vec { let ctx = Context::default(); let mut requests = Vec::new(); - let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), source, focused, read_only)); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), source, focused, read_only, &vault())); output.textures_delta.clear(); requests } @@ -44,13 +67,13 @@ fn pasted_local_image_url_is_consumed_but_plain_text_is_preserved() { let mut requests = Vec::new(); let mut remaining = Vec::new(); let mut output = ctx.run_ui(input, |ui| { - requests = state.collect(ui.ctx(), true, true, false); + requests = state.collect(ui.ctx(), true, true, false, &vault()); remaining = ui.input(|i| i.events.clone()); }); output.textures_delta.clear(); assert_eq!(requests.len(), 1); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save_path")); - assert_eq!(requests.first().and_then(|r| r.params.get("source_path")).and_then(serde_json::Value::as_str), Some("/tmp/my photo.PNG")); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save_path")); + assert_eq!(requests.first().and_then(params).and_then(|values| values.get("source_path")).and_then(serde_json::Value::as_str), Some("/tmp/my photo.PNG")); assert_eq!(remaining, vec![Event::Paste("ordinary text".into())]); } @@ -66,8 +89,8 @@ fn visual_unfocused_and_read_only_inputs_never_request_writes() { fn memory_drops_encode_padding_for_all_tail_lengths() { for (bytes, encoded) in [(b"a".as_slice(), "YQ=="), (b"ab", "YWI="), (b"abc", "YWJj"), (&[255, 255], "//8=")] { let requests = frame(&mut State::default(), dropped("pic.png", bytes), true, true, false); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save")); - assert_eq!(requests.first().and_then(|r| r.params.get("data_base64")).and_then(serde_json::Value::as_str), Some(encoded)); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save")); + assert_eq!(requests.first().and_then(params).and_then(|values| values.get("data_base64")).and_then(serde_json::Value::as_str), Some(encoded)); } } @@ -84,7 +107,7 @@ fn audio_video_and_pdf_drop_requests_use_engine_extension_contract() { let requests = frame(&mut State::default(), dropped(name, b"bytes"), true, true, false); assert_eq!(requests.len(), 1, "{name}"); assert_eq!( - requests.first().and_then(|r| r.params.get("ext")).and_then(serde_json::Value::as_str), + requests.first().and_then(params).and_then(|values| values.get("ext")).and_then(serde_json::Value::as_str), Path::new(name).extension().and_then(std::ffi::OsStr::to_str) ); } @@ -104,7 +127,8 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { let requests = frame(&mut State::default(), dropped("pic.png", b"abcd"), true, true, false); assert_eq!(requests.len(), 1); let Some(request) = requests.first() else { return }; - let result = registry.run(request.command_id, &mut session, &request.params); + let Some((command_id, params)) = command_id(request).zip(params(request)) else { return }; + let result = registry.run(command_id, &mut session, params); assert!(result.is_ok(), "{result:?}"); assert_eq!(result.ok().and_then(|value| value.get("path").and_then(serde_json::Value::as_str).map(str::to_owned)), Some(expected.into())); assert_eq!(session.vault().read(expected).ok(), Some(b"abcd".to_vec())); @@ -112,7 +136,8 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { let requests = frame(&mut State::default(), dropped("large.png", &vec![0; 1024 * 1024 + 1]), true, true, false); assert_eq!(requests.len(), 1); let Some(request) = requests.first() else { return }; - let result = registry.run(request.command_id, &mut session, &request.params); + let Some((command_id, params)) = command_id(request).zip(params(request)) else { return }; + let result = registry.run(command_id, &mut session, params); assert!(result.is_err()); assert!(!vault.join("Attachments/large.png").exists()); } @@ -120,7 +145,7 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { #[test] fn native_drop_uses_path_without_reading_the_handle() { let requests = frame(&mut State::default(), dropped("/missing/not-read.png", b"ignored"), true, true, false); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save_path")); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save_path")); } #[test] @@ -139,7 +164,7 @@ fn unsupported_virtual_drop_is_rejected_before_reading_its_handle() { let input = RawInput { dropped_files: vec![Arc::new(Unreadable)], ..Default::default() }; let mut state = State::default(); assert_eq!(frame(&mut state, input, true, true, false), []); - assert_eq!(state.error.as_deref(), Some(UNSUPPORTED)); + assert_eq!(state.error.as_deref(), Some(strings::unsupported_type())); } #[cfg(unix)] @@ -150,7 +175,7 @@ fn non_utf8_virtual_name_is_rejected_before_command_dispatch() { let input = RawInput { dropped_files: vec![Arc::new(MemoryFile { name, data: b"image".to_vec() })], ..Default::default() }; let mut state = State::default(); assert_eq!(frame(&mut state, input, true, true, false), []); - assert_eq!(state.error.as_deref(), Some(INVALID_PATH)); + assert_eq!(state.error.as_deref(), Some(strings::invalid_path())); } #[cfg(unix)] @@ -196,8 +221,8 @@ fn visual_image_paste_without_source_focus_shows_hint_and_is_consumed() { let mut state = State::default(); let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into()), Event::Paste("ordinary text".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - assert_eq!(state.collect(ui.ctx(), false, false, false), []); - assert_eq!(state.error.as_deref(), Some(SOURCE_HINT)); + assert_eq!(state.collect(ui.ctx(), false, false, false, &vault()), []); + assert_eq!(state.error.as_deref(), Some(strings::source_hint())); assert_eq!(ui.input(|input| input.events.clone()), vec![Event::Paste("ordinary text".into())]); }); output.textures_delta.clear(); @@ -210,7 +235,7 @@ fn visual_image_path_paste_into_another_field_stays_plain_text() { let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { ui.memory_mut(|memory| memory.request_focus(egui::Id::new("property-field"))); - assert_eq!(state.collect(ui.ctx(), false, false, false), []); + assert_eq!(state.collect(ui.ctx(), false, false, false, &vault()), []); assert!(state.error.is_none()); assert!(ui.input(|input| input.events.iter().any(|event| matches!(event, Event::Paste(_))))); }); @@ -223,8 +248,8 @@ fn read_only_image_paste_is_consumed_before_source_editor() { let mut state = State::default(); let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - assert_eq!(state.collect(ui.ctx(), true, true, true), []); - assert_eq!(state.error.as_deref(), Some(READ_ONLY_HINT)); + assert_eq!(state.collect(ui.ctx(), true, true, true, &vault()), []); + assert_eq!(state.error.as_deref(), Some(strings::read_only())); assert!(!ui.input(|input| input.events.iter().any(|event| matches!(event, Event::Paste(_))))); }); output.textures_delta.clear(); @@ -345,3 +370,81 @@ fn lightbox_escape_closes_without_vault_io() { output.textures_delta.clear(); assert!(ctx.data(|data| data.get_temp::(selected)).is_none()); } + +/// A PNG of the given size in the vault as an attachment named `name`, and the images of a note embedding it. +fn big_picture(label: &str, name: &str, size: [u32; 2]) -> Option { + let vault = crate::testsupport::temp_vault(label); + let mut session = Session::open(&vault).ok()?; + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(size[0], size[1], image::Rgba([200, 30, 30, 255])).write_to(&mut png, image::ImageFormat::Png).ok()?; + session.save_attachment(Some(name), "png", png.get_ref()).ok()?; + Images::load(&session, "note.md", ¬escraft_markdown::parse(&format!("![[{name}.png]]"))).ok() +} + +/// Paints the embed until the worker has shrunk it and returns the rect it takes (width 600). +fn shown_size(images: &Images, target: &str) -> Option { + let ctx = Context::default(); + egui_extras::install_image_loaders(&ctx); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + let mut rect = None; + let mut output = ctx.run_ui(RawInput::default(), |ui| { + ui.set_max_width(600.0); + let response = ui.scope(|ui| assert!(images.show(ui, target, None))); + rect = Some(response.response.rect); + }); + output.textures_delta.clear(); + if rect.is_some_and(|rect| rect.height() > 30.0 || rect.width() > 30.0) && !images.is_working(target) { + return rect; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + None +} + +#[test] +fn a_picture_wider_than_the_texture_limit_is_shrunk_instead_of_panicking() { + let images_opt = big_picture("attachment-big-square", "square", [4096, 4096]); + assert!(images_opt.is_some(), "vault"); + let Some(images) = images_opt else { return }; + let rect = shown_size(&images, "square.png"); + let rect_opt = rect; + assert!(rect_opt.is_some(), "never shown"); + let Some(rect) = rect_opt else { return }; + assert!((rect.width() - rect.height()).abs() < 1.0, "{rect:?}"); + assert!(rect.width() <= 600.0 + 0.01, "{rect:?}"); +} + +#[test] +fn a_tall_strip_keeps_its_aspect_ratio() { + let images_opt = big_picture("attachment-big-strip", "strip", [100, 10000]); + assert!(images_opt.is_some(), "vault"); + let Some(images) = images_opt else { return }; + let rect_opt = shown_size(&images, "strip.png"); + assert!(rect_opt.is_some(), "never shown"); + let Some(rect) = rect_opt else { return }; + assert!((rect.height() / rect.width() - 100.0).abs() < 2.0, "{rect:?}"); +} + +#[test] +fn the_shrunk_pixels_fit_the_texture_limit_and_keep_the_aspect() { + let mut png = std::io::Cursor::new(Vec::new()); + assert!(image::RgbaImage::new(3000, 100).write_to(&mut png, image::ImageFormat::Png).is_ok()); + let shrunk_opt = super::big::shrunk(png.get_ref(), 2048); + assert!(shrunk_opt.is_some(), "decoded"); + let Some(shrunk) = shrunk_opt else { return }; + assert_eq!(shrunk.size, [2048, 68]); + assert_eq!(shrunk.source_size, egui::vec2(3000.0, 100.0)); + assert_eq!(super::big::header_size(png.get_ref()), Some([3000, 100])); + assert!(super::big::too_many_pixels([100_000, 100_000])); +} + +#[test] +fn markdown_images_are_preloaded_like_embeds() { + let vault = crate::testsupport::temp_vault("attachment-md-images"); + let Ok(mut session) = Session::open(&vault) else { return }; + assert!(session.save_attachment(Some("tiny"), "svg", br#""#).is_ok()); + let doc = notescraft_markdown::parse("![alt](tiny.svg) ![250](https://example.org/x.png) \n"); + let images = Images::load(&session, "note.md", &doc); + assert!(images.as_ref().is_ok_and(|images| images.contains("tiny.svg") && !images.contains("https://example.org/x.png")), "{images:?}"); +} diff --git a/crates/ui-egui/src/text/features/paste.rs b/crates/ui-egui/src/text/features/paste.rs new file mode 100644 index 0000000..2f93482 --- /dev/null +++ b/crates/ui-egui/src/text/features/paste.rs @@ -0,0 +1,46 @@ +//! Copy for the paste path: the hints of the attachment input and the toast of a pasted file. + +table! { + source_hint = "ui.paste.source-hint"; + read_only = "ui.paste.read-only"; + drop_label = "ui.paste.drop-label"; + too_large = "ui.paste.too-large"; + unsupported_type = "ui.paste.unsupported"; + invalid_path = "ui.paste.invalid-path"; + clipboard_unreadable = "ui.paste.clipboard-unreadable"; + image_invalid = "ui.paste.image-invalid"; + unsupported_files = "ui.paste.unsupported-files"; + saved = "ui.paste.saved"; + linked = "ui.paste.linked"; + saved_and_linked = "ui.paste.saved-and-linked"; +} + +/// "Attachment exceeds the {{limit}} byte limit", with the limit filled in. +#[must_use] +pub fn over_limit(limit: &str) -> String { + too_large().replace("{{limit}}", limit) +} + +/// "`NotesCraft` cannot attach {{names}}", with the names filled in. +#[must_use] +pub fn cannot_attach(names: &str) -> String { + unsupported_files().replace("{{names}}", names) +} + +/// "Saved {{names}}", with the names filled in. +#[must_use] +pub fn saved_files(names: &str) -> String { + saved().replace("{{names}}", names) +} + +/// "Linked {{names}}", with the names filled in. +#[must_use] +pub fn linked_files(names: &str) -> String { + linked().replace("{{names}}", names) +} + +/// "Saved {{saved}} ยท Linked {{linked}}", with the names filled in. +#[must_use] +pub fn saved_and_linked_files(saved: &str, linked: &str) -> String { + saved_and_linked().replace("{{saved}}", saved).replace("{{linked}}", linked) +} diff --git a/crates/ui-egui/tests/code_font.rs b/crates/ui-egui/tests/code_font.rs new file mode 100644 index 0000000..4b4100d --- /dev/null +++ b/crates/ui-egui/tests/code_font.rs @@ -0,0 +1,26 @@ +//! The code font advances every character by the same amount (to the pixel grid): no ligature or pair +//! kerning opens a gap inside a URL or an operator. + +use egui::{Color32, Context, FontId, RawInput}; + +/// x of every glyph of `text` laid out in the monospace family, plus the end of the row. +fn xs(ctx: &Context, text: &str) -> Vec { + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.to_owned(), FontId::monospace(14.0), Color32::WHITE); + let row = &galley.rows[0]; + row.glyphs.iter().map(|g| g.pos.x).chain(std::iter::once(row.size.x)).collect() + }) +} + +#[test] +fn urls_and_operators_keep_one_advance_per_character() { + let ctx = Context::default(); + notescraft_ui_egui::fonts::install(&ctx); + ctx.run_ui(RawInput::default(), |_| {}).textures_delta.clear(); + for text in ["https://publish.obsidian.md/username/about", "a != b -> c => d // e "] { + let xs = xs(&ctx, text); + let steps: Vec = xs.windows(2).map(|w| w[1] - w[0]).collect(); + let first = steps[0]; + assert!(steps.iter().all(|s| (s - first).abs() <= 1.0), "{text}: uneven advances {steps:?}"); + } +} diff --git a/crates/ui-egui/tests/paste_clipboard.rs b/crates/ui-egui/tests/paste_clipboard.rs new file mode 100644 index 0000000..0a27d30 --- /dev/null +++ b/crates/ui-egui/tests/paste_clipboard.rs @@ -0,0 +1,185 @@ +//! Paste from the clipboard into a note: the image lands in the note and in the vault, a file of the +//! vault is linked and never copied. + +use std::path::{Path, PathBuf}; + +use egui::{Context, Event, Key, Modifiers, Pos2, RawInput}; +use notescraft_engine::Session; +use notescraft_ui_egui::app::NotesApp; +use notescraft_ui_egui::attachment_drop::{Clipboard, ClipboardImage, ClipboardSource}; +use notescraft_ui_egui::center::ViewMode; + +/// A clipboard the test hands to the app. +struct FakeClipboard { + /// What the clipboard holds. + content: Clipboard, +} + +impl ClipboardSource for FakeClipboard { + fn read(&self) -> Result { + Ok(self.content.clone()) + } +} + +/// A clipboard image of `width` x `height` pixels. +fn image(width: usize, height: usize) -> ClipboardImage { + let mut bytes = Vec::with_capacity(width * height * 4); + for _ in 0..width * height { + bytes.extend_from_slice(&[10, 20, 30, 255]); + } + ClipboardImage { width, height, bytes } +} + +/// The app over a fresh vault with one note, and the vault folder. +struct Driver { + /// The app under test. + app: NotesApp, + /// The egui context the app draws in. + ctx: Context, + /// The clock of the frames. + time: f64, + /// The vault folder. + root: PathBuf, +} + +impl Driver { + /// One frame with the events it gets. + fn frame(&mut self, events: Vec) { + self.time += 0.05; + let input = RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(self.time), + events, + ..Default::default() + }; + let app = &mut self.app; + let mut output = self.ctx.run_ui(input, |ui| app.ui(ui.ctx())); + output.textures_delta.clear(); + } + + /// The frames the app needs before it draws: the first one installs the fonts. + fn settle(&mut self) { + self.frame(Vec::new()); + self.frame(Vec::new()); + } + + /// One frame with the paste shortcut, then the frames that carry the answer + /// of the clipboard read. + fn paste(&mut self) { + let event = Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + self.frame(vec![event]); + for _ in 0..20 { + std::thread::sleep(std::time::Duration::from_millis(5)); + self.frame(Vec::new()); + if self.text() != "Hello" { + break; + } + } + } + + /// The text of a note. + fn text(&self) -> String { + self.app.session.buffer("note.md").map(notescraft_engine::Buffer::text).unwrap_or_default().to_owned() + } + + /// The files of the attachment folder. + fn attachments(&self) -> Vec { + files(&self.root.join("Attachments")) + } +} + +/// The app over a fresh vault with one note. +fn driver(name: &str) -> Option { + let root = std::env::temp_dir().join(format!("notescraft-paste-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).ok()?; + std::fs::write(root.join("note.md"), "Hello").ok()?; + let mut session = Session::open(&root).ok()?; + session.use_settings_dir(root.join("config")); + let theme = notescraft_themes::builtin("catppuccin-mocha")?; + let mut app = NotesApp::new(session, theme); + app.state.animations = false; + app.open_note("note.md"); + Some(Driver { app, ctx: Context::default(), time: 1.0, root }) +} + +/// The files of a folder, sorted. +fn files(folder: &Path) -> Vec { + let mut names: Vec = + std::fs::read_dir(folder).map(|entries| entries.flatten().map(|entry| entry.file_name().to_string_lossy().into_owned()).collect()).unwrap_or_default(); + names.sort(); + names +} + +#[test] +fn a_pasted_image_lands_in_the_note_and_in_the_vault() { + let Some(mut driver) = driver("image") else { return }; + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Image(image(2, 2)) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Pasted image "), "{note}"); + assert!(note.contains(".png]]"), "{note}"); + let saved = driver.attachments(); + assert_eq!(saved.len(), 1, "{saved:?}"); + let name = saved.first().cloned().unwrap_or_default(); + assert!(name.starts_with("Pasted image "), "{name}"); + assert!(Path::new(&name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case("png")), "{name}"); + let bytes = std::fs::read(driver.root.join("Attachments").join(name)).unwrap_or_default(); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); +} + +#[test] +fn a_pasted_image_lands_in_the_source_editor_too() { + let Some(mut driver) = driver("source") else { return }; + if let Some(edit) = driver.app.editing.tabs.get_mut("note.md") { + edit.view = ViewMode::Markdown; + } + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Image(image(2, 2)) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Pasted image "), "{note}"); + assert_eq!(driver.attachments().len(), 1); +} + +#[test] +fn a_file_of_the_vault_is_linked_and_never_copied() { + let Some(mut driver) = driver("inside") else { return }; + let folder = driver.root.join("Attachments"); + std::fs::create_dir_all(&folder).ok(); + std::fs::write(folder.join("pic.png"), b"picture").ok(); + let inside = std::fs::canonicalize(&driver.root).unwrap_or_else(|_| driver.root.clone()).join("Attachments/pic.png"); + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Files(vec![inside]) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Attachments/pic.png]]"), "{note}"); + assert_eq!(files(&folder), vec!["pic.png".to_owned()]); +} + +#[test] +fn a_file_outside_the_vault_is_copied_into_the_attachment_folder() { + let Some(mut driver) = driver("outside") else { return }; + let folder = std::env::temp_dir().join(format!("notescraft-paste-outside-files-{}", std::process::id())); + std::fs::create_dir_all(&folder).ok(); + let outside = folder.join("paper.png"); + std::fs::write(&outside, b"picture").ok(); + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Files(vec![outside.clone()]) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[paper.png]]"), "{note}"); + assert!(driver.root.join("Attachments/paper.png").exists(), "the file was not copied into the vault"); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn a_text_only_clipboard_leaves_the_note_alone() { + let Some(mut driver) = driver("text") else { return }; + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Empty })); + driver.settle(); + driver.paste(); + assert_eq!(driver.text(), "Hello"); + assert!(!driver.root.join("Attachments").exists()); +}