From 07d9997ee587723c32e2178be65eea584102bf58 Mon Sep 17 00:00:00 2001 From: Jeremy Singer-Vine Date: Fri, 5 Jan 2024 10:14:44 -0500 Subject: [PATCH] Standardize handling of cropbox ... fixing various issues with PageImage. Also adds force_mediabox parameter to Page.to_image(...). Thanks to @stefanw for flagging: https://github.com/jsvine/pdfplumber/issues/1054 --- CHANGELOG.md | 2 + README.md | 1 + pdfplumber/display.py | 58 +++++++++++++---------- pdfplumber/page.py | 74 ++++++++++++++++++++---------- tests/pdfs/issue-1054-example.pdf | Bin 0 -> 15374 bytes tests/test_basics.py | 2 +- tests/test_display.py | 8 ++++ 7 files changed, 96 insertions(+), 49 deletions(-) create mode 100644 tests/pdfs/issue-1054-example.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md index a83d1a2..8316c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ All notable changes to this project will be documented in this file. The format - Add support for PDF 1.3 logical structure via `Page.structure_tree` (h/t @dhdaines). ([#963](https://github.com/jsvine/pdfplumber/pulls/963)) - Add "gswin64c" as another possible Ghostscript executable in `repair.py` (h/t @echedey-ls). ([#1032](https://github.com/jsvine/pdfplumber/issues/1030)) - Re-add `Page.close()` method, have `PDF.close()` close all pages as well, and improve relevant documentation (h/t @luketudge). ([#1042](https://github.com/jsvine/pdfplumber/issues/1042)) +- Add `force_mediabox` parameter to `Page.to_image(...)`. ([#1054](https://github.com/jsvine/pdfplumber/issues/1054)) ### Fixed +- Standardize handling of cropbox, fixing various issues with PageImage. ([#1054](https://github.com/jsvine/pdfplumber/issues/1054)) - Fix `Page.get_textmap` caching to allow for `extra_attrs=[...]`, by preconverting list kwargs to tuples. ([#1030](https://github.com/jsvine/pdfplumber/issues/1030)) diff --git a/README.md b/README.md index 8e9741c..75034f9 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ To turn any page (including cropped pages) into an `PageImage` object, call `my_ - `width`: The desired image width in pixels. Default: unset, determined by `resolution`. Type: `int`. - `height`: The desired image width in pixels. Default: unset, determined by `resolution`. Type: `int`. - `antialias`: Whether to use antialiasing when creating the image. Setting to `True` creates images with less-jagged text and graphics, but with larger file sizes. Default: `False`. Type: `bool`. +- `force_mediabox`: Use the page's `.mediabox` dimensions, rather than the `.cropbox` dimensions. Default: `False`. Type: `bool`. For instance: diff --git a/pdfplumber/display.py b/pdfplumber/display.py index f8caa5c..44723d7 100644 --- a/pdfplumber/display.py +++ b/pdfplumber/display.py @@ -78,8 +78,12 @@ class PageImage: original: Optional[PIL.Image.Image] = None, resolution: Union[int, float] = DEFAULT_RESOLUTION, antialias: bool = False, + force_mediabox: bool = False, ): self.page = page + self.root = page if page.is_original else page.root_page + self.resolution = resolution + if original is None: self.original = get_page_image( stream=page.pdf.stream, @@ -92,43 +96,49 @@ class PageImage: else: self.original = original - if page.is_original: - self.root = page - cropped = False - else: - self.root = page.root_page - cropped = page.root_page.bbox != page.bbox + self.scale = self.original.size[0] / (page.cropbox[2] - page.cropbox[0]) - self.resolution = resolution - self.scale = self.original.size[0] / self.root.width + # This value represents the coordinates of the page, + # in page-unit values, that will be displayed. + self.bbox = ( + page.bbox + if page.bbox != page.mediabox + else (page.mediabox if force_mediabox else page.cropbox) + ) - if cropped: - cropbox = ( - int((page.bbox[0] - page.root_page.bbox[0]) * self.scale), - int((page.bbox[1] - page.root_page.bbox[1]) * self.scale), - int((page.bbox[2] - page.root_page.bbox[0]) * self.scale), - int((page.bbox[3] - page.root_page.bbox[1]) * self.scale), + # If this value is different than the *Page*'s .cropbox + # (e.g., because the mediabox differs from the cropbox or + # or because we've used Page.crop(...)), then we'll need to + # crop the initially-converted image. + if page.bbox != page.cropbox: + crop_dims = self._reproject_bbox(page.cropbox) + bbox_dims = self._reproject_bbox(self.bbox) + self.original = self.original.crop( + ( + bbox_dims[0] - crop_dims[0], + bbox_dims[1] - crop_dims[1], + bbox_dims[2] - crop_dims[0], + bbox_dims[3] - crop_dims[1], + ) ) - self.original = self.original.crop(cropbox) + self.reset() - def _reproject_bbox(self, bbox: T_bbox) -> T_bbox: + def _reproject_bbox(self, bbox: T_bbox) -> Tuple[int, int, int, int]: x0, top, x1, bottom = bbox _x0, _top = self._reproject((x0, top)) _x1, _bottom = self._reproject((x1, bottom)) return (_x0, _top, _x1, _bottom) - def _reproject(self, coord: T_point) -> T_point: + def _reproject(self, coord: T_point) -> Tuple[int, int]: """ Given an (x0, top) tuple from the *root* coordinate system, return an (x0, top) tuple in the *image* coordinate system. """ x0, top = coord - px0, ptop = self.page.bbox[:2] - rx0, rtop = self.root.bbox[:2] - _x0 = (x0 + rx0 - px0) * self.scale - _top = (top + rtop - ptop) * self.scale - return (_x0, _top) + _x0 = (x0 - self.bbox[0]) * self.scale + _top = (top - self.bbox[1]) * self.scale + return (int(_x0), int(_top)) def reset(self) -> "PageImage": self.annotated = PIL.Image.new("RGB", self.original.size) @@ -202,7 +212,7 @@ class PageImage: stroke: T_color = DEFAULT_STROKE, stroke_width: int = DEFAULT_STROKE_WIDTH, ) -> "PageImage": - points = (location, self.page.bbox[1], location, self.page.bbox[3]) + points = (location, self.bbox[1], location, self.bbox[3]) self.draw.line(self._reproject_bbox(points), fill=stroke, width=stroke_width) return self @@ -222,7 +232,7 @@ class PageImage: stroke: T_color = DEFAULT_STROKE, stroke_width: int = DEFAULT_STROKE_WIDTH, ) -> "PageImage": - points = (self.page.bbox[0], location, self.page.bbox[2], location) + points = (self.bbox[0], location, self.bbox[2], location) self.draw.line(self._reproject_bbox(points), fill=stroke, width=stroke_width) return self diff --git a/pdfplumber/page.py b/pdfplumber/page.py index 3ad1938..c458ab9 100644 --- a/pdfplumber/page.py +++ b/pdfplumber/page.py @@ -185,6 +185,27 @@ def textmap_cacher(func: Callable[..., TextMap]) -> Callable[..., TextMap]: return new_func +def _normalize_box(box_raw: T_bbox, rotation: T_num = 0) -> T_bbox: + # Per PDF Reference 3.8.4: "Note: Although rectangles are + # conventionally specified by their lower-left and upperright + # corners, it is acceptable to specify any two diagonally opposite + # corners." + x0, x1 = sorted((box_raw[0], box_raw[2])) + y0, y1 = sorted((box_raw[1], box_raw[3])) + if rotation in [90, 270]: + return (y0, x0, y1, x1) + else: + return (x0, y0, x1, y1) + + +# PDFs coordinate spaces refer to an origin in the bottom-left of the +# page; pdfplumber flips this vertically, so that the origin is in the +# top-left. +def _invert_box(box_raw: T_bbox, mb_height: T_num) -> T_bbox: + x0, y0, x1, y1 = box_raw + return (x0, mb_height - y1, x1, mb_height - y0) + + class Page(Container): cached_properties: List[str] = Container.cached_properties + ["_layout"] is_original: bool = True @@ -201,35 +222,34 @@ class Page(Container): self.root_page = self self.page_obj = page_obj self.page_number = page_number - _rotation = resolve_all(self.page_obj.attrs.get("Rotate", 0)) or 0 - self.rotation = _rotation % 360 - self.page_obj.rotate = self.rotation self.initial_doctop = initial_doctop - cropbox = page_obj.attrs.get("CropBox") - mediabox = page_obj.attrs.get("MediaBox") + def get_attr(key: str, default: Any = None) -> Any: + ref = page_obj.attrs.get(key) + return default if ref is None else resolve_all(ref) - self.cropbox = resolve_all(cropbox) if cropbox is not None else None - self.mediabox = resolve_all(mediabox) or self.cropbox - m = self.mediabox + # Per PDF Reference Table 3.27: "The number of degrees by which the + # page should be rotated clockwise when displayed or printed. The value + # must be a multiple of 90. Default value: 0" + _rotation = get_attr("Rotate", 0) + self.rotation = _rotation % 360 - self.bbox: T_bbox = ( - ( - min(m[1], m[3]), - min(m[0], m[2]), - max(m[1], m[3]), - max(m[0], m[2]), + mb_raw = _normalize_box(get_attr("MediaBox"), self.rotation) + mb_height = mb_raw[3] - mb_raw[1] + + self.mediabox = _invert_box(mb_raw, mb_height) + + if "CropBox" in page_obj.attrs: + self.cropbox = _invert_box( + _normalize_box(get_attr("CropBox"), self.rotation), mb_height ) - if self.rotation in [90, 270] - else ( - min(m[0], m[2]), - min(m[1], m[3]), - max(m[0], m[2]), - max(m[1], m[3]), - ) - ) + else: + self.cropbox = self.mediabox - # https://rednafi.com/python/lru_cache_on_methods/ + # Page.bbox defaults to self.mediabox, but can be altered by Page.crop(...) + self.bbox = self.mediabox + + # See https://rednafi.com/python/lru_cache_on_methods/ self.get_textmap = textmap_cacher(self._get_textmap) def close(self) -> None: @@ -542,6 +562,7 @@ class Page(Container): width: Optional[Union[int, float]] = None, height: Optional[Union[int, float]] = None, antialias: bool = False, + force_mediabox: bool = False, ) -> "PageImage": """ You can pass a maximum of 1 of the following: @@ -562,7 +583,10 @@ class Page(Container): resolution = 72 * height / self.height return PageImage( - self, resolution=resolution or DEFAULT_RESOLUTION, antialias=antialias + self, + resolution=resolution or DEFAULT_RESOLUTION, + antialias=antialias, + force_mediabox=force_mediabox, ) def to_dict(self, object_types: Optional[List[str]] = None) -> Dict[str, Any]: @@ -597,6 +621,8 @@ class DerivedPage(Page): self.pdf = parent_page.pdf self.page_obj = parent_page.page_obj self.page_number = parent_page.page_number + self.mediabox = parent_page.mediabox + self.cropbox = parent_page.cropbox self.flush_cache(Container.cached_properties) self.get_textmap = textmap_cacher(self._get_textmap) diff --git a/tests/pdfs/issue-1054-example.pdf b/tests/pdfs/issue-1054-example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..90917d110f340b34da31a4f27abfe2ef4bcebb65 GIT binary patch literal 15374 zcmajG1z43&6F01MCo?4h%L2C zZlrh{#m`S%=_cx9VzXhdXGi($tiLOqPko~=Id1$EmeGlg$bv|-)}Ti1t0xtWBQMVK zB@gUiQN*7z>73?9QkfN6#=_B+&bcLp`wr>fq9+@7Ap~_pSRXui>w`*c+BxO%CWmw)t`?l{F-w<>gaRgv6`KdELQUvW_)S8 z)RoKpmth?~7*2zA?G(-m<}>@JZiHvf48Gl3p*K26z>*_aG=kV#Z9iW2=r;Q;iF1_= z5tR2oSI9<1rtO7333y*rYK(QBMpD0~OWj7N=F?cxe&EeSQI@N#txM5fYDpYTt-SLs zi8Z2HsYZr1u?BR$91S1RW=-8&sGnhOaBs5Enxsr4elHj3M1rTmwlSP2)wJ-n9~)Bq zBa@-%Wv&@s9~F7@V3G7Y>aAbZ7OdZ4$bAA7d81m~7Y;i0IEt=#P8&jsR$(C0IN5w}G7A_3YH68T z9Y3|D^>fq5+GfNs1qC{0TJGgew5TOrn$LR%+G=&Y3I48T9ka!qRVZ$xKsRpkrG)~0 z5O>_!*}h7P#UdAl?R~UoCU~ULLjCco*0SR*XM*~`31-&o)V*|r(c}ioqR<31^rVKU zFUVK#s8dG(z+Ykz<2mgbrZhO^x6}6K(bVF(h^y^8ToOse-@2K2UNP(%YTG`pE%TQ| zAZJA@Uv;2JYz!VW4#LCEm1YYQo?k4u!{XM|{y+g+8vJ>Hprxl%2mIIRQ*^e19yeKC zqu+mRz?OCZ5VTnyz#tE{h1lEZgKYuKe-MNqmUhr~Tfo!lg)&Hi4NP>k%7r-FmWGANZG{)~6M-0lw{s)^Fl#SzWHc{vde+yF72Ljms zBLAb(Q>VXF`onO43kEPKNC6n3)A`p7o&^7YvdhRy0f7Hve$xAi89GG;u$8WjuAK?Q z62Kr}ZVq4&20NJOgN3BjBmlrCTgwA9|FRPB--{^?l?d$ty-qEk?D=OuF&kayzi(6~ zCU%zp`V*^b zfE=k7s-6J`eH0Q{kC#THi$>S??DW)$)=|Y#If<4}A?X%fIWqWxucc9g!-Vbn`{x|3 zut+?3h)P+D;15)?a)xCgH!b5gI*=dtBCAn&KLn8E%(HwIFrSIR;Plu=aC}aF&Su!x z7V#YMSu;(9SeO&0V69v}B^7oV^73}l9i>e%_OfJeQk?3KG}K3L?N>=?)6cCEKlG5> z8p2)$((-vFtp&VleT^g}_}qv1OHFN9BJ)=o)F8}{9Md1?DY4-r9lYraTv!=EXl2AY zT)LL*`M4M9q7tZUJv`|$bP?-aVfEh6QgLx8?|gYfZ2z&-xL~ZbJ(>?HTpC<&>zmCu= zV?@7c1w^;2;w+DTn)mv6NGYvb{1d?@Y5XT!5jm+c((C%Dy$^iR)$l%pn2IR+Sr#Zu z$U@#yl3zB(gs~pr)YGAgh=&CW@~V*$j^6az{;X*Z{j}v>oY_Q5TpmuD>gR;rHdMEq zZotk9uQSM1#%1L6E7zEsxdar>^&y*uX0`77ZmZUDqXw(`Kue=dTGM_UhTejP$1aTC z!IUXS>9POU7lo0=Db|`zP5UhdzZ6)-%n1`Grbip)7(X7fEq)$ioZQ~0OYx_ z3|iw`+XI*vc*()#FUTATd89#t`8=o>N*BDql+v)U7i7O8&97bE9vCPL#;<##ia_Dw zMia3wk+b{m!t1c;L!9w_`jTf5XYThk_wV;{_x*z?DNnnbJ0C(8dKaI+l{u-p!nk8! z^sZSY4uEe`zGGg@;J-c5KX$vdUYN#8|9UcwC2$(a=LYlP2-Tm1{if(^35o%4k9&`K z*qNAK9FJRtm~HgeD`T|q^1CH9X3j}{ngGykkV0RjVLfn1QY;5Cvb`fN=D3#0=b0K) zaonh+5MGGRER#^&8gG~WJN-HU8NM*+2n#pbp5bK?kuJebDpCc5f+ELaSv9VJ^O)%-w|DehMC7x1hnm$l~ShD;bzqg zZ}(iL9E>%TLCv0N{{*hm8x=IhsATNOs0+85h5PhQhtvi~SGpEMBhRHgK*S0O{f#kR zWV98C+kjZHtH&4V%jmN=Uyk3Kts6sHF~R1&rq{zM-*|nkrYK+Iw7bKnFT0rpN+yw0 z8(1$JzB8~UcT+Kn;hKBd9I(z4oa8eS?9_RW%(T7S4;B%}7iiA=fPJXr(t{WO*6gaF z;d8N84|?Jw9{XVj)E|KP-kHScI6#Q{L$KVK_PInmsEk(9ySE&) z&s0R9?qkvAp!0INMite8py9J-=b_GmD(MB{MU1z?IF-w5rXXT4OlM+Sy0BaXB@>le zR9%$3I6N30{5JbrMe0vs4yn2iJ6U#eDe?%`UkXld^mHVtHF>rkAqXKaH7Tmwj=M$wOkK5@pn-=ft`AxkMVoU4;p= z#&){wDC+rmVumttf70wEZr|=u>`ZO*oKL-A@q=$q#_0NB^Z{?5ZV2)S&pF z7@d|>QMIZ?=Bz-~ndyQlWZZt7c*1AGD6fA)CGSX?FWW=x-q+91FIhB9v|cp53nc_0 zgoyy1MT^NkzC@B<;fF>?5ttQN3QRRAFrGEFVKy>}F`YCfnQSbYDb3CR=VBLij(y5g zDxAo$moSr6SDBOIQs$Czt-H`VK%H&kv?Jz9mL>L*_8pQ=X5tLBv(_|Q(9vu@G^%f~ z8{WCt9zEaxRqHb2GU2kct@)F1WNI`uD>XyANW0Keqow~DTN%?b!8$<^GrNJ+*m7}t zJXyD_aqU3a=yAddO{TS|fT&<5btbH6vM3}(tt-8YqpL6^lz@+sOp-`yRcbTdoQcEe zC!aJVX^H)zw!qrIYk?&@jx*tX+z{J>$*eA?v7sf7<$IPn1Bb4nrTHJ+J=}9{$=Aqd zyr`PN&rr)dIfEl{WK?fx(P(0;!tG;NbWBX~b*XCRb)9={dioZrXA0(bKTp7#DVag` zB=_tmEhb@Eaaa{B`phq8=Bt#-e^q18Se9ATxPy%#v8FZShk2T%P2oIK&Ep=a_wI-< z{rUat(CpFJy2iR(Lx=HO?C-F}f-?Oxd&(zIDzG|x$3E^?PV5vgP0Vzwt!iw!^@}X{ zuF0=g3}??x{9u`_n*Wuz_Z^uP>nBA~$_VcY&zw&(>QarXC8uS$<%VUkrDrWy{n>)q z0z`5=4v*D(v|}@Hn1VwJQ+k$lr`o@yTQM}7#Dc`r*~Ph!n~>+W(Yz7VSnafMI(hc( zOzY5n(SMhz(QN#<56LCTGAVOSA6IEz;)LEqxu~G zxi-8V{3nD{m^~OzvsVM}0FanlDB!YVqxc zV;1-cXMr;s#A35Mn}4ZquKx}wrY}d!=3ue!!+u*^XT8)twz{@zw&p2iB|#}+EHf^Z zlw8Yp0{$7575(utf;eKM*Mcg6ib5u49C=2LZco@KoD|!cdAFag#^?NHKy0dk&wTCG-&$eo!)ne$V_OyPemFbgJ zTMe~Ui%bSnJ;g=ZpT{KNaOmEGfzNs7Y$M>tpHwQowY}f}g=_kL!h9*MUZ6SMO#i%VQ;IcF*}|npt+MXB zgZP0LU5#is*{|8^G>eeA!DDY5nq76GvUJ;UeJ zN2Yo3>S8lJPsqZ*37yTu=yoCIZM|4QLRn5kj#p+zrskl@po;n%_1RDNpF-V^Ztf5D zW4cX4_eiy!1%C;52BK3)$e^V}r0nt~+uz8|?N_@R9N=(K6pX-ey0I?3NLuch)kk-?y*34jPW0EQ~Bosl{ba1Z5aDKO*u6Dtd;&SM$LB!Gjkj^jIeFC-^kp*3fbn@Qpt_97` z*@5^7ZRhZc<7CS<^eQyvLH@YEAtgwg#iQ8S{G>L0s_9ZyTXiX#Vl4HXJDQhxxAacx zwuWw)D8>EKb>xT`b_~HKr9Z{D*{eXMVXo)|tUyOR0&~qiHMTGV!7& zA3};g=qlz}b5eTZS=Ais;`u1F=#T!0fQ){n_rFm434;EK>OKky3F_K{4FJC(+rJ>( z6W;z$nD_+8ZT}l!vjRZ?HjXE_ECjKK;#4TqlQ1!`g<|3-2ndz;FE9yZ{co8bXHc-$v-|C| zcp^fL@MP3KS_GaRK^g@+u!R-aTnvh#Eg+T#bn;N`p-B3PM;L6YZ)0L*2eCl`{^`XK zv4naYO)Q~?k4l!ovC=-#_$!qJp4fKlOY1 zK8^T9gT5$F-#_N|H2xF+ACy1l@gIBd-_G5C7VaO@|FIO(x)x9m#s6c;%yo^R?j{g) z4?!1C@V7UK4#Wrq&@n-$31nmf0yx-Mpu6rPbZ?vJ3s@SNgP~e72-xaBIkKMY1NDLY zQK17tol^`#x>g^+CPv0~DC|to>eE(Hh7JH=Q2m1pU53y6yn7q%oL4Yz=U;E{Paq$^6|VR}5VI`| z@cc#3J5zph#7f|hgD2WH)y;A7rF5D5LTSU&Z8P7ZOW|aJQsHFX*8cZP$m4|_i-3MN zoC$LRVoZ^f^Ui5iXmbpHVgyNIBj^)pazqTl*_B^q)b{u&yyWLexI;q1TF<+tZD+;H zskWBp5B%Ua+${O`5BLlV#LS|ZR3_RnF)6t+UuAk;cp6*|96Jwl-`ib9#8qmRuas_p zmqVD^w51o4b7W-3#EiPS2GG#8Ba(=kb?w|mul%OJs=Bj2TtYCvJkkp~>4`eo)$+3P zVnEVELy(8Z#kxCkg!dxb##Y-@C~|VZo7vrd3~Kl!$plOkQlu#Xj{QBe(cyTJc*Qbb z2d~__m)P#+lD(O)u(DpF)C*xT>*|g8IKuM?!13U|uC&t|F(VTQ6c8{G5SS4Vm`qDs zO-r*C5FkZ>cgJ~MiDO1X$sT}I0oPRMEPEDV*pS1YY0QiN-FYB-8P~_P%e#Dknjvs1 zK&Zph7}wPE-OQ_38H)l-C#h@S=Ie|b9o+h^B(OdS;z}j~Pwq5V9Giet`ajVf=O~sq z>UL7t?2+q9kEH6SJeMLbPZR>E#M&HuHPbgoLw~m2S0m=|hCO5c@vHsR z&ok+$+%@s?uS6H&S&t{-;Bj!7J#X9+Y8Qem8XdPIcwTSEZq71laJ<+G@jzTkLc(9r zxxPGV(Qe%SCFOe-!_eR0L@-bA`2*YIYEU@o67hqz!9?~oQw*&uo(A~Qej{5wx}L4d zONy-qgRsi@uDHb0otAm+%8D`R8I7IQgSr*(yJgihN@=z9tbG@+!T$asgl%1Bp64Qx zxg(9bl?dNZ5o1?;Q9oB{>bWz<*Du8^=3p6j1@AdeIXolRw22k`4Z@8)p& zhH1(~UR6*~jq}5QiCvyOsp+mP3Lz1F=}rVLp!o#@5zP(Kq}76WjT#I-Vb^IOq?$KjQZ{9H#d@NkapNxAEm<^k(z*EX%AjMO0~0NL$r=aE zS1C*S-u04i@Z#oC^SYnH-J6UhfQ>;9+@B8x*5WIh)Z&%M!q`P#dAcuO&Du-%JpMWMhGSLJj=*^qVIxaHK{J^2(#n()I|#;$rKQXv&x zNkB#LwU#Vs-Dh}=rpMxLviLQHEs2%D9wv=Az8ja*3eAT#R1ZE(B2~2S_Es0?65G#h zLEYFe(p0{1a&I_zl$eu>EhrTV<|Xf6d6`x=>VS|XxjB_h-tK-%Y=n%wg)}ldo)n&? z7*1}WzZJ08pXLX&0tErma!}-V>bs5NCyLIAUP3O6;^|Ljf$dNQ8z`h!vrG@Ed${E;) zOIgJ(f#WZ9C=+Dl#LpeIWZm7$*xJkxBNZ6_a!hLmrdrVnqAg7E1C7|_Rcsj5zK2^{ z%jqiR;CnGv=F81?G}*ML-E634`l&Sd>#lNV z`7ax)EuUS1%5E9CcczGLq#*l)6n5xC&IBg1J@L3?);4KXg^5&xx-uEa=yWJ6S1{j` zD`Ih67RnG8kr`Ucu&sy^(6V^yj}mg|2C!G*zI|kvj%f_mdi6Luah6P~t!t@(I{(nJ z4X<^l;vt~Y{Xnzzr9If{=K}8Xl?CqmDH11j+$CcZW*-FGOsd_bky*+OuU?qs#U}5BB!b88(=9zm){(+GZv^tyZmP`Fx(BY2}z|*ffqVY}Cx{ zxOG2?7m{D*?4%IJn(3GmstKAZUN5kiTky^~j%as&_>`Gxec;NR#QlP_jbA;`Pzc3B z;KUd{A>?#~nf-Yu|6|q4WVIun5L0;bMb&w?NhQA2XIn;tV8F{G-(YJZBvF~nk#4rk zkG7cRRZ3d9DyN{MHbcF5R_5aud;ZcbIcNn|xaMeR2JJWt&gbvsV$xo#=6@}AOK%;* z!B2AI2y=DGN;y0;hu2)iapz6XGIrNGXmvxNVx!nDo{8tU)-~QM?yn=X*c>!h_5184 zjvX!a}j7HY_QT*Ua=B$rmg;h9EU2c0w~)kt`+5f**tnHb<>O+{&_eja%%^CvKG`9RA_G zLSR`1cppl3La9OlEIoZEG1`DYB|T%G1zpAfw{jnjZsJ!{3fh1g>3m2S!L~NBwz=3^ zUOo-$plY?AS4P@xj48i(UL;Q=>vFKi3X_~=V zk=E9@)vw{bH(^-|!QZvBM+KST30F+6FstXduV%MWA?l-j`&4H*l$G*i^-8T<$Bd$Q zf_6n>2KN%oL85Z(CAsNtao%`%CmY>W2N0%oFS|+4GqNJT+SwVyw;oz^|=y`MPrqSMgsW{kMzWAES1@Q%4EQc_{GM# zNv|Kgroo|3n=OqR(M*X`-9$A%vz(%E-y<&HUcXb@M;C`bI6;; zE(gRI!%3w5jJsb z&EZ6D)~nRI!UWxu^s(;i9Xj8e#ChTTq$>bFlis-G5H~l~jWc~^J8}N%`}!tNW#ROO z9`!*n#jK{7DOI%nY>-nc{pU8R>$*KS=5L8_rj_Eo7$Q{)#ihMcD|6AXi*0~5AYB_} zcAiRVDim0ukE!iu@RV8&u51llobn4&6zg2Hk{G>#MIdG!SNNro5Z%jsQr1TVF5Ko7 zpO$T{ssvVRKoAAHf2Sq(AW6=3(5&nDaI?&92WF_cRr!FSZ-DIdtR+fJyUDr{%Spq$ zq}LmD?#kg}r+Cgor#*1?yimS($IL*Z4g%qKrWCksjH`@`iiQW2&nvUl%C28SPu};7 zM0^DKMwZTe5YoyxJ`1JOGe|OJzX@CvgI=l^gs^Z9vhpW1U@9nf$HmYZr)z zCuWP@jtCBG1}rG=cfZBf-v|UMM~eq3<#uyN0W9oJ6V3HI!K(H)O;N^FQA{?^3y*2xgm;Tu&q(Jw1;4J_*TnpwwMkPsET*(BNb^1~*JBN6 z;}39Al1eYe@ek>xP@*}hYfr9CM){?m+koq%^S;@O<+GjGY!nGTvqVsE-WxTxa#!L8 zFgPMXdXDbf8^pKRJ+vn#*}AZ{5;F9gs47_~-lNY4q#NFRUV}CDJeo7dKcMTrEGBVx z%jx4HRUwHTvp!SkcY!>g%xGrH;D6!x{+46OP{7l+nw?;BeP%QE-Cd+^h{S@B?*0AE*Jz6L!GrUL;Of*t|wUS!!q`?>(mpE;Pw<@-0pT;ol4 zeo_N8@*GR425WYkleDMS4G%1@3c&|3^wOvA(lLm(Rqh{OMCYnb`O4AXAfgW5$>Jtk zEDiZEC*j3YV(n}Opd5^P{G#+bNv0(di@i-SRm|BDYXis7D*FSF08B%Mpw4Y6-I#er zOD^Jc5yy?m_7h1R3IvcpUH+1eH1kR$d5$WJQkjvax2HLOjjCTwuT03#d%qg#yoX5m)P zAR!Clbc{QxKs90<*$)*U2A>Lr-9!omMxED2f%2=e2QmR6?gRYD+}C6qSZ1#~!F`Uf zwGWd+t1lpa5~PM<*vi>TYB9+JY_juwHu$`bC=t3DLW)HE0>Rh@`~r%CTB1W}s1O9h zpNH%7X{9nePR9VQ5B(*pm<%)rizdf4j1<8zYl~U*0mVXT9Tc@DBFYPr@Kk5wWFto# zzdnAn0lCkOLDqIphWH2bb*`=g==BmgX4jW#MRNz?zm4a^A`X9_sN6qy;%0u za)aUV)Q?VWA8VT&pSk7tSD+z(y%ElTJ2(@_Enkl1-JqG5n<#x_${DBd;B%CeF_e>}WshfPp{03Edw=Ejwk96PdG!Vr zjD0{atMyiCM66>Vd4P_g&e_Kzc0tQPpZq&&l9QMP$!s@C22aGU1IV)cOQcuFRd2TxV@rG^y_tx$YUrQ>3b{G#*VQIqS2u! zZhE6$QFL5otL2FD*skcAj?3kHtDli#BpgEncwbHNbB53_v|UO)wOlJYbZp16TZBGNg5j2e1B1$@yg@A7kV@>i zc4t&^wBxH9`QF^R^|~KfEVghA?EAITF{-@?1P@s$ZjPUe(`gjT;cnwJ$tTS*tXX{Y zuSW*0r~NL!!fCZG+N0BBjM!Rzg7aIaY4Lce^4uwsdooJ{Ax_autX=!(d)6CYB9_q2 z17Z67Wqw0!o(~e~@|bj=9^d(0qngx4pTGL-zJ6PLh?+^|CCD7!C}|$3Q)8~}dtKYJ zNl6gmO=Ml6t-+4+em*RIYYOgEN1E}j@MHH!%%kk`mYDnj!sErl<6k!`5M3=*HUHe& zu9^2zC-s3YKMP(}#~$}Tys?-ev!rI`;VJE-1&+k#7Q?Ac)5 z#Ae3|w+dTsG|%^^F11l&ZC72dRIRiTtUmR5%vkw zUz?JNgIGn|D7eierqr<=NVfMUn? zPOM09%EHap_x2QNLSO-AvDbYO?jq&UFOs~ahXjDe>HwcICHzD;Ma`=5qU9`D3fhP? z8vsw}0s$5i%9>T=*N8<{d|QTbQ3%fh+TLbksjb3k^EQMYvD&xUUJ%L!=2d4Tgvld*B$w$ zk98x|qd#kEZ@R%;n3`VDZdt>SQ}sd;z&A5<#@v0%iWOHA-@{726G@3x z!e6wkLDAaqMUQR5Wnj#0hPVlXeT0TH*tchd;{Cf3*RK#}HhbmRy0{jiuI$Zf4Nrgp zlGgC38}P-#+g8urDKIJrhhj)|qa>}__WsB1w3XNF4=Q;+>A{|&SZw=}(t&z9urqJ% zFJ(bA$mW98__>iYUt0Ylar-t6z|Si;Fi0j<6!|qp#O!~)hl4@u`6Pz^giZf63O zI5K5YBYnAI;c0L6!auguRFo9FBXHsL>+j(bYk3Ic*h}<`D=DOcuzyeE#ay^V8XBR< z4C_a4KbczOc7I^ZDY1YTdfvuIeeH#@?LFoLn+RKDcmI>0Zv!*fqA>(lwT#)fr)sA- z?uy5}j<_;@zM36nr}yXlRHF+4kV%db#q|}0>tdjMaY$%DG{O<5=cV@1CCl2trwt_% zq?t_^&Lze9E?Q-~6IaaB>Yr!Tk1oF=VO#F5S5E>|ESKPNu2T0E6ZedxaRf@dVU zjHQKfs^^R0GQYzaqj}c=(U<3#=kb<4(b{CsCUvV(gVgj~>L*ezXv~3h!CDNNVHE(| z{=q)LXUKzKJ+~emno@+&=5rJ;vr6J2J`z~E+l%p^EE_B=4?NA4EaBxC`I4r>E4x+W z3WU8cln0nN4(L(OyjWjPTrm7~!K}MZ*@Ww9z^r@N&Tifry8MQqZ_|q&r3pQ3 z%|bfc`dMqT#CG#h!!33LE(P50`FdPU>nGU3TKjW0z+e-R(!HcA+-T7oNyp6sK3Zwb z2jhW}{7)($<3oY)?|0}Yi0f@kmV#8YCFaUpz-`$zR-%nB&Gt)ctiQ4#M3(>5x>*|0 z*(8EdO_1KFW;|2w`@|aLcL)=%F14`xrUrL9^F^aE3{FI(6WjK_B9|F@`2w@_P$S7G zdC{xHT|8Cs>S_`Q_lFks%6S1k0D^|%kW52j0cA%N2?l4I? zI)BO?v!zaJfs~x}oWQo=Bx$X+5N!8XaU`yX=cSKE$fG5XIbXiRu-W&9+*DV0byc&v z>)TXlrG5=< z-LnT@WS5Oy@!`(PQli=x5!I^5C|+=1CJC;Qv=Ez^*c?&$+dE*iQy!pPI*bM*yg+qM z;+~&6qNrzCHgW%pzAWBo*yd1;tV@yhP&i~mN$xqt?6~_utQxY|vR)}Q_S5*Vfv(*Z zM@aMrl3nYHuVQPt=-yKaBi#wOCQg|bv{p<-S)dVSvosLPAUy<5ioSFn^M<(3g*}$0 zJ2ag5o^Tf^)|%lR)`i5#*cTTUhve=E`HP*zemoS%zO+J%fq##g8vxp#FFjmZ>Z)GL z4;FJkt?iH9eRsiL7bJ>u({nM2MR&WC;x#aZ-|;wSGPe-NfZ{5d?-E_`GROvN z+DAAr@5o&wFKPEw&0(i4oVuhFl9OznF;zBMP5K$CNIB75eV=To^frSA#MW-r+GX@<;l%*+Z* z8!BklrVdr>X#z3c?NA8fXgKgLnGV57U26XIihMb>P<0_^zw+U>zKYSb@&=R0 zuupJRxf}-VJP*_U(=QN zsw~QwF?U3n|7=ne5eBw%Rt*wTEl*E={@rVPdbwrpSQoE{t4yq^Vavnm%Fvnl`uy}n z&RI1_gE9|SMP%b~Kf~psyX+X^tVASH+b`H zS{hV}z1M*0yKinU#}lQ&hhf#<0;$#kN~{4r!`* z%t}%M^+uB+eX#zR0B2&y;fByYn!iu>>Bsr+Dz1dnAHvOBKgp_+sfp-@g$ zE6$E#rU=r!;KCC~v}0K@?VEU|9^Eu46dl#KAwBXQqav#ZFHLKH1>P^jTyK16)aGl= zTyca_{`ThC{D7Algv|6=xGBb=N}P~nLH~XRSfs-}adfoJ;soaChg7Q<y6aMxpvCg-~rTYp>t#o;kH(IPbx(`F%q&FfvKO z%16kpIFlPgS}66)J>kmZ3z?l1!M)?j$qWDV^e+Xb80yDv)zVg+8k=Dwa~SdpXl(8Q zM}E0S4R&aVDRJz_NXIEuBJ2;Z@hy}ybzTXc!i|Ott<;OdgbbQ0%<)!7I()1c-Z(~* z{8kN6D5fs9C^Q2j45hkyQ}*YN%;mSixIX_db&9q))Q1pH#gRXmva6qH^*C@qkgdFE z;^_T)A}x+u=mm>0rkiv&??X;wMV6#on<-(R=7G(Je1!L-x*(2_Lk z-7T}B7|3o+A5jkG|MXwTX`=DMXvu;3)Iy--w?3Y}Q%Ww}^{KcMfpab@Cbx8`m@sd_ zvmk`yTyDzsJh5 zrHF9GDelT~f{Vd2&tHzt?{mq4usLt)QN9uqF8f=a z)IM{OJCymS)7LSd4NorS;oixlhfFmms)I0I~h|^_lKL(b|lW(&{6}z6% zYKP|K?N}}!{C75%`pWvEn3($dn3$sa%F3c5=4Wj4w+xTE1Ittb5!z?>j6=R?Xo#MV z+4Qrrs$U{WCIR@O$PsI5C~WrRTF;4-O~noXa~~vXA)lq_g6Vnbg$`l8MAmOfxMA_m z9QMr29J4yJy1mb*E3fdVGayEgciSP{wHCzKsZ~txJGEwExFfnHl)$d2zkK8I)xUv> z>OEFZDYhJv0}c^K??;;u^ERt<+z-+$9-C9J#wloSaa-z5Z@;mZ={GqC{t{tzft((` zNqQN`PFPWY{hW{zn|o7{`uv9F#I=@q2?_M3>(^1s;g1%ZLRg8uG}JAT*%j*tE`OR! zmJre-2D&D855dnZh>PR*GK&Maum3kH2^!e+udpN&J7~ZOr5M-(Y+`8(4QR0iNSRog z*g_LH4Yu)ZCDQozCv8Y*K9RY4JKV*Z;+ zOiIxFw?cm@t3l}qhVs|}jEoHFp{iQ~gl+8g%^dzzPFGKZ3Mx_vnw0qzB_{loYeXr` z31S2>LG@$zxkN{myem_fAwQw1^&;tEbMImJuZ-$1sd=4_ZW6I zy3o)ho8Jj<3MMXKs7;>|?I6&=!KdIrX#U^v-}Z=Xln=Es=rqg2wfW%6moJA8Bvt~1C-L| A8vp