From 3521e6c2a3c3fa45982b3021786bc10a0bf4bfef Mon Sep 17 00:00:00 2001 From: Lukas Stockner Date: Tue, 17 Sep 2024 15:06:34 +0200 Subject: [PATCH] feat(jpeg): Support encoding/decoding arbitrary metadata as comments This is needed to port Blender's current JPEG IO code to using OIIO, but is also a useful feature to have in general. For reading, the code tries to parse comments as colon-separated key-value pairs and sets metadata accordingly. For writing, this needs to be explicitly enabled by setting jpeg:com_attributes to 1 in order to avoid accidentally bloating files for existing applications. Signed-off-by: Lukas Stockner --- src/cmake/testing.cmake | 2 +- src/doc/builtinplugins.rst | 8 +++ src/jpeg.imageio/jpeginput.cpp | 20 +++++- src/jpeg.imageio/jpegoutput.cpp | 40 +++++++++++ testsuite/jpeg-metadata/ref/out.txt | 62 ++++++++++++++++++ testsuite/jpeg-metadata/run.py | 23 +++++++ .../jpeg-metadata/src/blender-render.jpg | Bin 0 -> 10780 bytes 7 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 testsuite/jpeg-metadata/ref/out.txt create mode 100755 testsuite/jpeg-metadata/run.py create mode 100644 testsuite/jpeg-metadata/src/blender-render.jpg diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index bedf2faf48..3ed8a561a2 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -144,7 +144,7 @@ macro (oiio_add_all_tests) oiiotool-demosaic diff dither dup-channels - jpeg-corrupt + jpeg-corrupt jpeg-metadata maketx oiiotool-maketx misnamed-file missingcolor diff --git a/src/doc/builtinplugins.rst b/src/doc/builtinplugins.rst index 8bc3f7e93f..27ba0167e4 100644 --- a/src/doc/builtinplugins.rst +++ b/src/doc/builtinplugins.rst @@ -1037,6 +1037,10 @@ anywhere near the acceptance of the original JPEG/JFIF format. reader/writer, and you should assume that nearly everything described Appendix :ref:`chap-stdmetadata` is properly translated when using JPEG files. + * - *other* + - + - Extra attributes will be read from comment blocks in the JPEG file, + and can optionally be written if ``jpeg:com_attributes`` is enabled. **Configuration settings for JPEG input** @@ -1084,6 +1088,10 @@ control aspects of the writing itself: * - ``jpeg:progressive`` - int - If nonzero, will write a progressive JPEG file. + * - ``jpeg:com_attributes`` + - int + - If nonzero, extra attributes will be written into the file as comment + blocks. **Custom I/O Overrides** diff --git a/src/jpeg.imageio/jpeginput.cpp b/src/jpeg.imageio/jpeginput.cpp index b00f2d96b7..13427a6c00 100644 --- a/src/jpeg.imageio/jpeginput.cpp +++ b/src/jpeg.imageio/jpeginput.cpp @@ -284,10 +284,24 @@ JpgInput::open(const std::string& name, ImageSpec& newspec) && !strcmp((const char*)m->data, "Photoshop 3.0")) jpeg_decode_iptc((unsigned char*)m->data); else if (m->marker == JPEG_COM) { + std::string data((const char*)m->data, m->data_length); if (!m_spec.find_attribute("ImageDescription", TypeDesc::STRING)) - m_spec.attribute("ImageDescription", - std::string((const char*)m->data, - m->data_length)); + m_spec.attribute("ImageDescription", data); + // Additional string metadata can be stored in JPEG files as + // comment markers in the form "key:value". + // Since the key might also commonly contain a colon, the + // heuristic to separate them here is that if there are multiple + // colons, the first one belongs to the key, the second one is the + // separator, and any further ones belong to the value. + auto separator = data.find(':'); + if (separator != std::string::npos && separator > 0) { + auto second_separator = data.find(':', separator + 1); + if (second_separator != std::string::npos) + separator = second_separator; + std::string key = data.substr(0, separator); + if (!m_spec.find_attribute(key, TypeDesc::STRING)) + m_spec.attribute(key, data.substr(separator + 1)); + } } } diff --git a/src/jpeg.imageio/jpegoutput.cpp b/src/jpeg.imageio/jpegoutput.cpp index 04237d8aff..fc48cc9ba6 100644 --- a/src/jpeg.imageio/jpegoutput.cpp +++ b/src/jpeg.imageio/jpegoutput.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -117,6 +118,15 @@ OIIO_PLUGIN_EXPORTS_END +static std::set metadata_include { "oiio:ConstantColor", + "oiio:AverageColor", + "oiio:SHA-1" }; +static std::set metadata_exclude { + "XResolution", "YResolution", "PixelAspectRatio", + "ResolutionUnit", "Orientation", "ImageDescription", + "Compression" +}; + bool JpgOutput::open(const std::string& name, const ImageSpec& newspec, OpenMode mode) @@ -229,6 +239,36 @@ JpgOutput::open(const std::string& name, const ImageSpec& newspec, comment.size() + 1); } + // Write other metadata as JPEG comments if requested + if (m_spec.get_int_attribute("jpeg:com_attributes")) { + for (const auto& p : m_spec.extra_attribs) { + std::string name = p.name().string(); + auto colon = name.find(':'); + if (metadata_include.count(name)) { + // Allow explicitly included metadata + } else if (metadata_exclude.count(name)) + continue; // Suppress metadata that is processed separately + else if (Strutil::istarts_with(name, "ICCProfile")) + continue; // Suppress ICC profile, gets written separately + else if (colon != ustring::npos) { + auto prefix = p.name().substr(0, colon); + if (Strutil::iequals(prefix, "oiio")) + continue; // Suppress internal metadata + else if (Strutil::iequals(prefix, "exif") + || Strutil::iequals(prefix, "GPS") + || Strutil::iequals(prefix, "XMP")) + continue; // Suppress EXIF metadata, gets written separately + else if (Strutil::iequals(prefix, "iptc")) + continue; // Suppress IPTC metadata + else if (is_imageio_format_name(prefix)) + continue; // Suppress format-specific metadata + } + auto data = p.name().string() + ":" + p.get_string(); + jpeg_write_marker(&m_cinfo, JPEG_COM, (JOCTET*)data.c_str(), + data.size()); + } + } + if (Strutil::iequals(m_spec.get_string_attribute("oiio:ColorSpace"), "sRGB")) m_spec.attribute("Exif:ColorSpace", 1); diff --git a/testsuite/jpeg-metadata/ref/out.txt b/testsuite/jpeg-metadata/ref/out.txt new file mode 100644 index 0000000000..06b3d809ed --- /dev/null +++ b/testsuite/jpeg-metadata/ref/out.txt @@ -0,0 +1,62 @@ +Reading src/blender-render.jpg +src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg + SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB + channel list: R, G, B + ImageDescription: "Blender:File:" + Blender:Camera: "Camera" + Blender:Date: "2024/09/17 15:50:17" + Blender:File: "" + Blender:Frame: "001" + Blender:RenderTime: "00:03.49" + Blender:Scene: "Scene" + Blender:Time: "00:00:00:01" + jpeg:subsampling: "4:2:0" + oiio:ColorSpace: "sRGB" +Comparing "src/blender-render.jpg" and "no-attribs.jpg" +PASS +Reading no-attribs.jpg +no-attribs.jpg : 640 x 480, 3 channel, uint8 jpeg + SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F + channel list: R, G, B + ImageDescription: "Blender:File:" + Blender:File: "" + Exif:ColorSpace: 1 + Exif:ExifVersion: "0230" + Exif:FlashPixVersion: "0100" + IPTC:Caption: "Blender:File:" + jpeg:subsampling: "4:2:0" + oiio:ColorSpace: "sRGB" +Reading src/blender-render.jpg +src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg + SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB + channel list: R, G, B + ImageDescription: "Blender:File:" + Blender:Camera: "Camera" + Blender:Date: "2024/09/17 15:50:17" + Blender:File: "" + Blender:Frame: "001" + Blender:RenderTime: "00:03.49" + Blender:Scene: "Scene" + Blender:Time: "00:00:00:01" + jpeg:subsampling: "4:2:0" + oiio:ColorSpace: "sRGB" +Comparing "src/blender-render.jpg" and "with-attribs.jpg" +PASS +Reading with-attribs.jpg +with-attribs.jpg : 640 x 480, 3 channel, uint8 jpeg + SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F + channel list: R, G, B + ImageDescription: "Blender:File:" + Blender:Camera: "Camera" + Blender:Date: "2024/09/17 15:50:17" + Blender:File: "" + Blender:Frame: "001" + Blender:RenderTime: "00:03.49" + Blender:Scene: "Scene" + Blender:Time: "00:00:00:01" + Exif:ColorSpace: 1 + Exif:ExifVersion: "0230" + Exif:FlashPixVersion: "0100" + IPTC:Caption: "Blender:File:" + jpeg:subsampling: "4:2:0" + oiio:ColorSpace: "sRGB" diff --git a/testsuite/jpeg-metadata/run.py b/testsuite/jpeg-metadata/run.py new file mode 100755 index 0000000000..e57628eef4 --- /dev/null +++ b/testsuite/jpeg-metadata/run.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + + +redirect = ' >> out.txt 2>&1 ' + +# This file was rendered and saved in Blender, and therefore contains metadata +# in the form of comments. + +# Check if the comments are correctly decoded as attributes, and that writing +# to a new JPEG does not include them by default. +command += rw_command ("src", "blender-render.jpg", use_oiiotool=1, + output_filename="no-attribs.jpg") +command += info_command ("no-attribs.jpg", safematch=True) + +# Check that, when jpeg:com_attributes is set, the attributes are preserved. +command += rw_command ("src", "blender-render.jpg", use_oiiotool=1, + output_filename="with-attribs.jpg", + extraargs="--attrib:type=int jpeg:com_attributes 1") +command += info_command ("with-attribs.jpg", safematch=True) diff --git a/testsuite/jpeg-metadata/src/blender-render.jpg b/testsuite/jpeg-metadata/src/blender-render.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1bac83e098f531f044fbc92753173fca0f29b84e GIT binary patch literal 10780 zcmd^lc|4Tg`}aLF_AJqmrN~xe-`9#k_F{}JqeW?is4S`Uktsz@$X-br%g7Q^gwmq2 zwqP2RR1{hIq>UCWe&^ix==1r#Ue6!T^L;(9-}A?#X59BV*SXHMo$H+QzRW$D8$uSk zI5;~XI2?lDz&~Ve7!h@&?{cK?LJ-7o?l&T58%E!`jUH*~5E4eWT(xIsbVzgz{Ju--_pPg(8$lzc!h-1E%o*1{~Lh5Me}_Qkz3GC z*tmGU(GIjlZn4CEfez{U4$naPPP!%f4VstEH&anD|4d_~Yiu$146#ENE)W$L68=UwboiO1Ovb=B?X)w9$N%eB8Y~E z(hG!CZEKpPsXZUmjJ8G|78a3NEGs9kzI+8qL(|yA)Xdz%(r%5tgQJtPi>H^jkMDXv zTHrQ%(DvYv(5UD=F?;vzk4-poG%@Meab`wlR(8&*(`RxEi;CGL7fQ>@FJGyxyISAS zcP38wg0*cnEHZSfpZjNQh{gUQp94tZGF4AiXuRM?^-= zcv$@-R1MYae^0T)|B+^Y6#GZ70YsF51I{BzBG$-Qh(<7ruaHSX3UinUsgQ|ikO~`p0WZbqNWd9R zg}%Z0ux4})I2o9k1YTsAD0eU)CKnCJ1BQbEKpw~keBm>oxKIKp3LBLfZ3V>W6i`Zy z5U5fHk0E%#bmDaZb--L0AB+xF3zZ2K4l)HS&`F_6=l~JG9Zn8}16|M}_zYk*R6;gS z6LeC@j7JDnp0_Hd38oV0h9;ahF=|VwASyH3H*b2(n%Imya-Nz@)CYKAGWfx4!vqdP z4GAUV@rT9+Bcb$=7L^%&0p5Z0pxi<$*eC; z$_|47MknmYf(T`VSpHCO(bF(ni5Q}iV(NqW(2#}#0_&hA_@l{u{O~Lb*4Mn9d1gU%!lDJn8R`=r z54gPMS0B$o*!lp*f(sZm7?>3G@4UGyJl3z%6jUIuHVny0``#~9`PEhQN56wAMPCrl zFz|Ir`!zv~69~2E_lpF(RSp*S9VOyC!nQ6| zcJE~S@3z!D$toLX3EkT?J-o2X)1AH49?{EKxyUKtYgs8vq&igLy-GdTORwbi^YH!; zvXvqvg7Wlvjuk^O-nVj@R5gikrhnPrBz^#dhLjzZjix|2g7vokP zZ5(`#n1uSRP3k!yf~1!iy0CUPF7e6U=J>njU}4=j&NN{ljdh`0U>&li(OOO2QXd5OkHnJyD z2&dS@3QkrxAw(9h9}+?$Ebx6SlDSItc~-n-OVtyGgILt;)!Z%1_xX-k?-S?ZGQk*} zt1~cCZ59P*uLG?Bbf)%@`PJbI?4fcXi7RYTM}V>vJjcV8ENoA#1^ws)Xn`Y$S+z(O zkQ5k7{a*|rjjKI26z2*J?d;~gJ)m581&fFboF?BtJV{nnUHz8Ywr>4LwIQP~o9+~H zzh5Kt*dw0AY1=V;3A6X?sqc<&eU4~W9Fb)S6_FDN)s@Z;7HlGtbky4A=!zWcMpcPc zcIK2Br@Wj09(U*BGM`x{=ka@)spYDPo{7?ohjq@=(u(Q(zxa2r|2^fNeXEsWIZ$<1 z)P8;G=u$Dn(apU{qju!S9fo=#vohW+J8bvCpdpID#=#eR?wajPm~g8J@=x(ja%E1iB;z%1G&ALHT##OH%KXa3{-RrdKbQkD}SK1q(iAQt}T!zYA94? z=X%|AFLUH)%BNeLYab#FS9bGB)4mKnTeDs%w^|y>xwv#^*f~ZwpP*mE7~A>F@tu-1 z&#oimR%%&89>MYY1?r}ArYRtEM~X(wHf2i)j}sYB7<=`%C4 z{VP`)+9*4FRY>$*w=Af5rrqcEL?Lab0K)=oE107(Pw|{b!fBw+MO_I)0(u(y4*DPX z4)r(|9vQ&fB+T`I1jGqAM4%5+%-n(JRcZs`$Yf~Lg>;V1HzvZir!NS7_)Y;QDk*vg~u~{CHsyUlQYuyYU27{cTZQ;Y;GNN zGF95d#YLvS@tWu;&EKSd;bUnju1nS0vyE+<>{Yh&eV5I9vZQCaTvw-Xg{@GLLf-UL zte1Pd{JFFWmlsTPmhihuH@DBdBZ%Oua@DfX^AtI+tCycil9u^B69DlU%M&t9zr0QWG z&W;ljIis$2kN2`eR5d7*krxfckcc0$QbNm88>^BWxoP)u9$#I$&u6vJ^u=GB^mJ!V z$Mq}RQk-#NtfaN{8ox9g-6nCY%i~4i<2_L_Dvj!ySH3FIxJ>~cTPcZxX^yc-@v+hZ zuK@AG`Ii#+#4OdAJU#Mw9QX7>`J%rymHSGZ4VqcP(zerOgenV_irOQ2%h*mItO6e* zE}vUu1)QaA%sb3WM{5s9|BX^MVzll=6hSi=VZmiX zCtaR2^x!(`|BAo-^0i;)z{8#K2jo274ls0|PJXgKa{lY_w}bI1;Wn#$g>SA9wa$(V zkTEALuk?3Rb6a9zyP8BODSdO|fo)NIhaaku) zBhGg+v`(CMcV7JY!tsUEd#qEo^pF({H)scZl90|;O^hVp{!;oG?sxeT#4KEeNK$?B z*Jnkk`BTo`t%e;_LQm(tjp3^{g)#79M|b7dcNCj?n<7{;w_ylE&oN zEtdc`OIUZA>xsR`ubH<{I2(^hgzL?=D}>C(RFWR~PIv`*$W$v-D@JcKYb4@){py$F zNWTfQK8C6mcQjTETr2CP3Hr!^!%^27FIfumg(V^xGBw!#%#rn$mXod>@i#Rl^^9?6 zuJ|-gnHFx9T8o@;lV0HDeeCwDeD^84*ly%<_Ft*_+2L-(xop|{ahmPs#yC=$rhb$= zi~k|@`s45_o0WW#d&Cn2O~Uee$g&RMp6hm3S@p>579N|Kw3hF`+OU{1S~T#So5#&q zmSO5S=)J(yV34@;r#zB6HE5&jVOtR^KC|hnZr~!@`!7ppd(Cws5;H%sgjk325=VXe zs;<-wWrRG}v_!NP)BYMPoJ02P2rZJ|w#aS!SzR~%8%L3-t6#1RUR6%6zqRFCU(40Q zOtTG*fxmi(4(_>Bo?b%a_js~fQ&=~9pT{-EvW+V3@VI(0)c*0se)hG4s>X>QzSw-{ zDAZ5WkaOXz&DTW(#|w{FzRndmo)x|&RnVopKeb_nY}Z=vQROVj_Ep`2N)Zd5J^ER`%&}4gMk~6B=78N2?6x4H(G7GyRMAZo&Ang)vZY$Q#$Wr2VnEsCI zV(gIewZYOe%Sk z+|5UwLoOO$>8vRZeL50U9;&Au#`YJvJ5#oNmuc&ZO+(?Z-d+DqWhoQb& zBkRq?S{5>0I`NKkhRnbAlMokoKag$bkvW))C^|%aR=QIZc<7yj@CDqQ!f zC~mrIM$J4wOhZDVm-@w9hU6;ut(ik^mTxI|DqcP|$^BTg{^a+q?mvaP`8DW{h^oL$ zbH!}fvm%2(gZ&+XR=F-c?Qvc#)J&R)}*Di=1Vjf2!-&^P_$={?4^M z0iCjn5+=T$hjg`{`nY9NRbNjQ<|_++qjL=-|S_ z`-l{m@hy?0Q99L=%?`f0qUoe=V0A#T7FV!bB<=39z5^P^?<%VbE=%p*>Cib6ZQ_4Z z3<+tAP^<|5vcKK`P^Veh#Uux&ZL$#ETqQfJK(zRz?Mm;WRf_}SN^7HVCk-6z;_MM^ zT9S?CZ!WX}YQjJ~V{)k~hrSJQi@XgCfFrO3nW;cHjdpdc@G5pB%)Q+I10=LJ?ev$Ai z_{>y^+2L9G&2puiq%1bsBlhTpMMr%#CBKQnf1sghT#kHykF@97+?B^(blQ4<&RWsW zsbe4!e)8u|oUwWwCjKF^IrbO0WmVWK`wVCGwpV*xQv2>+9a?(rT+?Jl+@fMeK>Mv> zYFsv5p>nx%63cwcH;Mo=ZMRMPh0N5tlM1e)1qbOCBk~RF>pgRk%iFzIMNtXq>0Z5l zCp(49imcWvA6wC_n1!=Y7FcKdU;{^LIxTPF#3+3NM7ZmPgc zNe@|KwWgAKmD^x;QsYwpIi%8^LJv=vI@uJ@rpXcrEEe8meU>IdjFHxjH1gnd6Ha)n zRTHnx-(H6}`^6qnQ2$K)tFr$sO~7tk^W0;(?Wwv?J6H=$C#ig%PrUYC-oJYZcjnB+ z`&U(N9YwY*%5bmz=kkUo7VIWR#4dVR1nQ{s?ut4@D$G5Q1l$VIfxDDVfAp-|IX?=7 zD-2oQ$CcQi;C~@B#vxP$*Mh9yE8F7xzD4)%8MaMk3nJ3BNPyZIXVXHnJn~6b?a4^= zRz`00j_hV>W0?pe8LhUGeJs9DeYR#-+FzWhkbH*gwf!MpXQ;`#Q%V>(M;0ny%&N#a z`6+94qcpYn=oc4>ozu7i2m~0s_Wfd2hf7WzaH`OLB7OdVs-tn(g*5uM?5!I{ZPeV#B%sV_OV3y@`nNz zFFMriWEgK%7y4q>qVUC~@UWr7Po)b?ij9^&xW4(v{pRwBkiwT*Z)B*E;ub7{rcc)V zmsShn&*80pzCIayPG!xwwR{|6v0IS%_eol{+Q+uu%W?eP86Te=QE&ulR{V$K4ZGaR z(3bjb8KIG6xfKWn&-G9KwBI97{2k?f#H}MgZr-fhc}J>7PUy#Tt1R4BQlFYo(INNZ z7IO;zRj!-HJ#Kg(dh+Khzk2J&_5CZwvyIA?D>;!{_?Sz{d=7R%a+$K z53ez(3Z3~nSkHRJSO%|*|M_W*q-qRqOClW4@n!M|omjke9?1h+Ad2XL-xMbc0kC&o zS=whFaXx~kzrp4bPG#&Rv!#$>xy)wVW|rD-dGY>NXC*sN^^pk<^VqM7ezpyicSNb>Z6|bli8N>Fj{{gqhj`?5ruXP|`(dlVSqlG; zy0PiM8r{B@+B%QfBD8?b`JICCod4jVBa~-<(C)G6aFt zv8{&+x1Jt=0D*=7@3oHYKM=!Z^{J|j}vJ=)BDCMn3L+TqEUeRUTf z->*yF+qw=}FC^4%l3m_0I_Pl7)kvH_w5<7|PtMbz8&dhURV2K*c3_TlP>^WsW8I5? zotC%%>-ANwHM{jS)Z7ghyT8W+2xYdbtIoAF>5;$^@o(?_q&Xoer(YO7=`$?~4ZmQn zq!mfRvw7R&;)^d0q*qM!=x|mn_^iY!+Xn6>8P56cP5Y)x$JqnHxSlni&7Jzy`bJYT zmylPzY>IcgQ#NtM2KOMP_Lr95u6ORvk3X8T7N{h@D)K+(KlLDK{7zBEq-1k8xVZvM z?-j*5F~Q>ZJL-|lb+yJJV!1S-ceYs8DUZ_p*494;R^teN-0SwT*6ydSB~vBKIPNrB8`{u9gzJ+kYe2QS+LBXIe16bI|2h%lS&p$?Xp$FCKXEvAM-F zgYMoG@$Rk9^pD-lAi8E(w<3#z6Q$b4KJXp+YkEw2b#M8GR_`S9p;<5PPl}*|b~rIb zA=xlbK77ZHFT=~7xw~HrzV$N(FsHc)vNkA7M+YLiKx=A`Gk|i{0XUq6LcH@RGDLr`X%(dF=TQl`bUwf&)&hQD2>3uRAIdSf%A2WC4D2agEwx4nuP?W)2CyoQ~X= zICHSD(dAvmDek+QU9m$F**EDdk$_kZ=U;0X3lMaJ#}+*Vaj76F@Ei}q1!BM;LqJsc zPb)?;A6oOd1Ja`B+ke6j4ah7IUNGWNP#GTEgfToCM%;XSuA2-2z!5_&#jx3t8sXoG zFZJja^-)jLiOz2`smmlwl&Sn^Wp$LPJaT3&AbJ|c1$WG_1i5vamzO+5EVDz-umy9G zj4LMB<4vEq6un9Qon4e0mhe~Dg)|!?uD!=bR!xx$ro+4s+yeB&pncGS`3U$4I1J|u z9R|t>{NQkomIgozT6lnA!E-au-&oiHrvIu*puvGMgfRg_4y{K};aZ(%Gfoli55aNY^F1UuyBZU8xC^9|=Z2cO+Jdg*%mv(&_34KhY z?{R;4sa0`qorizblZCOQG7>KI_K??t`y%4nRR5HIq1EGrZB_yX z4xcC176&aLF9H|KCbSR13Uij2C5=xT5@pZj?b5)YJ+@;KCN{<>RQTzdv4M36PRB(+#&}>P2FqD5gx0*_RMpsN?BAgKG>)*N29%QL!QI_0KOWfin zkL*1zI_accR3?3i&({NAEcsJ=C57M7J|c9a03fh+cD34Q6wq*9f(nuQM{F*DsIN{+8;fe^5ezp~@8q zqt79GO&;~T#u$a@&mjT*CSak1%m%Alyj>G4)c?^+8LxH*FJk*){S~Iq6JoE+;SDX7Peu=hKl5usV-A{wY*&!cw zBCbfcL~W-kNy0Oaqg9by_HaAruuVINIP;OXhVdIw{R z4E@6b^ym-&Oyd82F<&3ZB376g43glYaD})DE`<>Dqo0@tL*?yAP-pf0kZ?HC}4@s4!R&{`KJ{l z0kl9hp8OEpF;P$fF|E)ZG{XKrkLJ0j{{@Q(7%Kn( literal 0 HcmV?d00001