From f4c4f2553bf827b2f0c1ecc1fff706e2aa2bf313 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Wed, 17 May 2017 09:44:55 +0200 Subject: [PATCH] Rework docx parser, add comments to docx --- CHANGELOG | 1 + openslides/core/static/js/core/docx.js | 356 ++++++++++++++++ openslides/core/static/js/core/pdf.js | 8 +- openslides/motions/static/js/motions/docx.js | 393 +++++------------- openslides/motions/static/js/motions/pdf.js | 6 +- openslides/motions/static/js/motions/site.js | 2 +- .../static/templates/docx/motions.docx | Bin 7506 -> 7609 bytes 7 files changed, 460 insertions(+), 306 deletions(-) create mode 100644 openslides/core/static/js/core/docx.js diff --git a/CHANGELOG b/CHANGELOG index 565331646..2c372ed82 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -26,6 +26,7 @@ Motions: - Bugfix: Added more distance in motion PDF for DEL-tags in new lines [#3211]. - Added warning message if an edit dialog was already opened by another client [#3212]. +- Reworked DOCX export parser and added comments to DOCX [#3258]. Users: - User without permission to see users can now see agenda item speakers, diff --git a/openslides/core/static/js/core/docx.js b/openslides/core/static/js/core/docx.js new file mode 100644 index 000000000..7ec74b3bd --- /dev/null +++ b/openslides/core/static/js/core/docx.js @@ -0,0 +1,356 @@ +(function () { + +'use strict'; + +angular.module('OpenSlidesApp.core.docx', []) + +.factory('Html2DocxConverter', [ + '$q', + 'ImageConverter', + function ($q, ImageConverter) { + var PAGEBREAK = ''; + + var createInstance = function () { + var converter = { + imageMap: {}, + documentImages: [], + relationships: [], + contentTypes: [], + }; + + var html2docx = function (html) { + var docx = ''; + var tagStack = []; + + // With this variable, we keep track, if we are currently inside or outside of a paragraph. + var inParagraph = true; + // the text may not begin with a paragraph. If so, append one because word needs it. + var skipFirstParagraphClosing = true; + + var handleTag = function (tag) { + if (tag.charAt(0) == "/") { // A closing tag + // remove from stack + tagStack.pop(); + + // Special: end paragraphs + if (tag.startsWith('/p')) { + docx += ''; + inParagraph = false; + } + } else { // now all other tags + var tagname = tag.split(' ')[0]; + handleNamedTag(tagname, tag); + } + return docx; + }; + var handleNamedTag = function (tagname, fullTag) { + var tag = { + tag: tagname, + attrs: {}, + }; + switch (tagname) { + case 'p': + if (inParagraph && !skipFirstParagraphClosing) { + // End the paragrapth, if there is one + docx += ''; + } + skipFirstParagraphClosing = false; + docx += ''; + inParagraph = true; + break; + case 'span': + var styleRegex = /(?:\"|\;\s?)([a-zA-z\-]+)\:\s?([a-zA-Z0-9\-\#]+)/g, matchSpan; + while ((matchSpan = styleRegex.exec(fullTag)) !== null) { + switch (matchSpan[1]) { + case 'color': + tag.attrs.color = matchSpan[2].slice(1); // cut off the # + break; + case 'background-color': + tag.attrs.backgroundColor = matchSpan[2].slice(1); // cut off the # + break; + case 'text-decoration': + if (matchSpan[2] === 'underline') { + tag.attrs.underline = true; + } else if (matchSpan[2] === 'line-through') { + tag.attrs.strike = true; + } + break; + } + } + break; + case 'a': + var hrefRegex = /href="([^"]+)"/g; + var href = hrefRegex.exec(fullTag)[1]; + tag.href = href; + break; + case 'img': + imageTag(tag, fullTag); + break; + } + if (tagname !== 'img' && tagname !== 'p') { + tagStack.push(tag); + } + }; + var imageTag = function (tag, fullTag) { + // images has to be placed instantly, so there is no use of 'tag'. + var image = {}; + var attributeRegex = /(\w+)=\"([^\"]*)\"/g, attributeMatch; + while ((attributeMatch = attributeRegex.exec(fullTag)) !== null) { + image[attributeMatch[1]] = attributeMatch[2]; + } + if (image.src && converter.imageMap[image.src]) { + image.width = converter.imageMap[image.src].width; + image.height = converter.imageMap[image.src].height; + + var rrId = converter.relationships.length + 1; + var imageId = converter.documentImages.length + 1; + + // set name ('pic.jpg'), title, ext ('jpg'), mime ('image/jpeg') + image.name = _.last(image.src.split('/')); + + var tmp = image.name.split('.'); + image.ext = tmp.splice(-1); + + // set name without extension as title if there isn't a title + if (!image.title) { + image.title = tmp.join('.'); + } + + image.mime = 'image/' + image.ext; + if (image.ext == 'jpe' || image.ext == 'jpg') { + image.mime = 'image/jpeg'; + } + + // x and y for the container and picture size in EMU (assuming 96dpi)! + var x = image.width * 914400 / 96; + var y = image.height * 914400 / 96; + + // the image does not belong into a paragraph in ooxml + if (inParagraph) { + docx += ''; + } + docx += '' + + '' + + '' + + '' + + '' + + '' + + ''; + + // inParagraph stays untouched, the documents paragraph state is restored here + if (inParagraph) { + docx += ''; + } + + // entries in documentImages, relationships and contentTypes + converter.documentImages.push({ + src: image.src, + zipPath: 'word/media/' + image.name + }); + converter.relationships.push({ + Id: 'rrId' + rrId, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/' + image.name + }); + converter.contentTypes.push({ + PartName: '/word/media/' + image.name, + ContentType: image.mime + }); + } + }; + var handleText = function (text) { + // Start a new paragraph, if only loose text is there + if (!inParagraph) { + docx += ''; + inParagraph = true; + } + var docxPart = ''; + var hyperlink = false; + tagStack.forEach(function (tag) { + switch (tag.tag) { + case 'b': + case 'strong': + docxPart += ''; + break; + case 'em': + case 'i': + docxPart += ''; + break; + case 'span': + for (var key in tag.attrs) { + switch (key) { + case 'color': + docxPart += ''; + break; + case 'backgroundColor': + docxPart += ''; + break; + case 'underline': + docxPart += ''; + break; + case 'strike': + docxPart += ''; + break; + } + } + break; + case 'u': + docxPart += ''; + break; + case 'strike': + docxPart += ''; + break; + case 'a': + var id = converter.relationships.length + 1; + docxPart = '' + docxPart; + docxPart += ''; + converter.relationships.push({ + Id: 'rrId' + id, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: tag.href, + TargetMode: 'External' + }); + hyperlink = true; + break; + } + }); + docxPart += '' + text + ''; + if (hyperlink) { + docxPart += ''; + } + + // append to docx + docx += docxPart; + return docx; + }; + + var replaceEntities = function () { + // replacing of special symbols: + docx = docx.replace(new RegExp('\ä\;', 'g'), 'ä'); + docx = docx.replace(new RegExp('\ü\;', 'g'), 'ü'); + docx = docx.replace(new RegExp('\ö\;', 'g'), 'ö'); + docx = docx.replace(new RegExp('\Ä\;', 'g'), 'Ä'); + docx = docx.replace(new RegExp('\Ü\;', 'g'), 'Ü'); + docx = docx.replace(new RegExp('\Ö\;', 'g'), 'Ö'); + docx = docx.replace(new RegExp('\ß\;', 'g'), 'ß'); + docx = docx.replace(new RegExp('\ \;', 'g'), ' '); + docx = docx.replace(new RegExp('\§\;', 'g'), '§'); + + // remove all entities except gt, lt and amp + var entityRegex = /\&(?!gt|lt|amp)\w+\;/g, matchEntry, indexes = []; + while ((matchEntry = entityRegex.exec(docx)) !== null) { + indexes.push({ + startId: matchEntry.index, + stopId: matchEntry.index + matchEntry[0].length + }); + } + for (var i = indexes.length - 1; i>=0; i--) { + docx = docx.substring(0, indexes[i].startId) + docx.substring(indexes[i].stopId, docx.length); + } + }; + + var parse = function () { + if (html.substring(0,3) != '

') { + docx += ''; + skipFirstParagraphClosing = false; + } + html = html.split(/(<|>)/g); + // remove whitespaces and > brackets. Leave < brackets in there to check, whether + // the following string is a tag or text. + html = _.filter(html, function (part) { + var skippedCharsRegex = new RegExp('^([\s\n\r]|>)*$', 'gm'); + return !skippedCharsRegex.test(part); + }); + + for (var i = 0; i < html.length; i++) { + if (html[i] === '<') { + i++; + handleTag(html[i]); + } else { + handleText(html[i]); + } + } + // for finishing close the last paragraph (if open) + if (inParagraph) { + docx += ''; + } + + replaceEntities(); + + return docx; + }; + + return parse(); + }; + + // return a wrapper function for html2docx, that fetches all the images. + converter.html2docx = function (html) { + var imageSources = _.map($(html).find('img'), function (element) { + return element.getAttribute('src'); + }); + // Don't get images multiple times; just if the converter has not seen them befor. + imageSources = _.filter(imageSources, function (src) { + return !converter.imageMap[src]; + }); + return $q(function (resolve) { + ImageConverter.toBase64(imageSources).then(function (_imageMap) { + _.forEach(_imageMap, function (value, key) { + converter.imageMap[key] = value; + }); + var docx = html2docx(html); + resolve(docx); + }); + }); + }; + + converter.updateZipFile = function (zip) { + var updateRelationships = function (oldContent) { + var content = oldContent.split('\n'); + _.forEach(converter.relationships, function (relationship) { + content[1] += ')/g); - - html.forEach(function (part) { - if (part !== '' && part != '\n' && part != '<' && part != '>') { - if (isTag) { - if (part.startsWith('p')) { /** p **/ - // Special: begin new paragraph (only if its the first): - if (hasParagraph && !skipFirstParagraphClosing) { - // End, if there is one - docx += ''; - } - skipFirstParagraphClosing = false; - docx += ''; - hasParagraph = true; - } else if (part.startsWith('/p')) { - // Special: end paragraph: - docx += ''; - hasParagraph = false; - - } else if (part.charAt(0) == "/") { - // remove from stack - stack.pop(); - } else { // now all other tags - var tag = {}; - if (_.indexOf(TAGS_NO_PARAM, part) > -1) { /** b, strong, em, i **/ - stack.push({tag: part}); - } else if (part.startsWith('span')) { /** span **/ - tag = {tag: 'span', attrs: {}}; - var rStyle = /(?:\"|\;\s?)([a-zA-z\-]+)\:\s?([a-zA-Z0-9\-\#]+)/g, matchSpan; - while ((matchSpan = rStyle.exec(part)) !== null) { - switch (matchSpan[1]) { - case 'color': - tag.attrs.color = matchSpan[2].slice(1); // cut off the # - break; - case 'background-color': - tag.attrs.backgroundColor = matchSpan[2].slice(1); // cut off the # - break; - case 'text-decoration': - if (matchSpan[2] === 'underline') { - tag.attrs.underline = true; - } else if (matchSpan[2] === 'line-through') { - tag.attrs.strike = true; - } - break; - } - } - stack.push(tag); - } else if (part.startsWith('a')) { /** a **/ - var rHref = /href="([^"]+)"/g; - var href = rHref.exec(part)[1]; - tag = {tag: 'a', href: href}; - stack.push(tag); - } else if (part.startsWith('img')) { - // images has to be placed instantly, so there is no use of 'tag'. - var img = {}, rImg = /(\w+)=\"([^\"]*)\"/g, matchImg; - while ((matchImg = rImg.exec(part)) !== null) { - img[matchImg[1]] = matchImg[2]; - } - - // With and height and source have to be given! - if (img.width && img.height && img.src) { - var rrId = relationships.length + 1; - var imgId = images.length + 1; - - // set name ('pic.jpg'), title, ext ('jpg'), mime ('image/jpeg') - img.name = img.src.split('/'); - img.name = _.last(img.name); - - var tmp = img.name.split('.'); - // set name without extension as title if there isn't a title - if (!img.title) { - img.title = tmp[0]; - } - img.ext = tmp[1]; - - img.mime = 'image/' + img.ext; - if (img.ext == 'jpe' || img.ext == 'jpg') { - img.mime = 'image/jpeg'; - } - - // x and y for the container and picture size in EMU (assuming 96dpi)! - var x = img.width * 914400 / 96; - var y = img.height * 914400 / 96; - - // Own paragraph for the image - if (hasParagraph) { - docx += ''; - } - docx += '' + - '' + - '' + - '' + - '' + - '' + - ''; - - // hasParagraph stays untouched, the documents paragraph state is restored here - if (hasParagraph) { - docx += ''; - } - - // entries in images, relationships and contentTypes - images.push({ - url: img.src, - zipPath: 'word/media/' + img.name - }); - relationships.push({ - Id: 'rrId' + rrId, - Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', - Target: 'media/' + img.name - }); - contentTypes.push({ - PartName: '/word/media/' + img.name, - ContentType: img.mime - }); - } - } - } - } else { /** No tag **/ - if (!hasParagraph) { - docx += ''; - hasParagraph = true; - } - var docx_part = ''; - var hyperlink = false; - stack.forEach(function (tag) { - switch (tag.tag) { - case 'b': case 'strong': - docx_part += ''; - break; - case 'em': case 'i': - docx_part += ''; - break; - case 'span': - for (var key in tag.attrs) { - switch (key) { - case 'color': - docx_part += ''; - break; - case 'backgroundColor': - docx_part += ''; - break; - case 'underline': - docx_part += ''; - break; - case 'strike': - docx_part += ''; - break; - } - } - break; - case 'a': - var id = relationships.length + 1; - docx_part = '' + docx_part; - docx_part += ''; // necessary? - relationships.push({ - Id: 'rrId' + id, - Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', - Target: tag.href, - TargetMode: 'External' - }); - hyperlink = true; - break; - } + // Data for one motions. Must include translations, ... + var motionData = { + // Translations + motion_translation: translation, + sequential_translation: sequential_translation, + submitters_translation: submitters_translation, + reason_translation: reason.length === 0 ? '' : reason_translation, + status_translation: status_translation, + comment_translation: comments.length === 0 ? '' : comment_translation, + // Actual data + id: motion.id, + identifier: motion.identifier, + title: motion.getTitle(), + submitters: _.map(motion.submitters, function (submitter) { + return submitter.get_full_name(); + }).join(', '), + status: motion.getStateName(), + // Miscellaneous stuff + preamble: gettextCatalog.getString(Config.get('motions_preamble').value), + pagebreak: PAGEBREAK, + }; + // converting html to docx is async, so text, reason and comments are inserted here. + return $q(function (resolve) { + var convertPromises = _.map(comments, function (comment) { + return converter.html2docx(comment.comment).then(function (commentAsDocx) { + comment.comment = commentAsDocx; }); - docx_part += '' + part + ''; - if (hyperlink) { - docx_part += ''; - } - - // append to docx - docx += docx_part; - } - isTag = !isTag; - } - if (part === '' || part == '\n') { - // just if two tags following eachother: --> ...,'>', '', '<',... - // or there is a line break between: \n --> ...,'>', '\n', '<',... - isTag = !isTag; - } - }); - - // for finishing close the last paragraph (if open) - if (hasParagraph) { - docx += ''; - } - - // replacing of special symbols: - docx = docx.replace(new RegExp('\ä\;', 'g'), 'ä'); - docx = docx.replace(new RegExp('\ü\;', 'g'), 'ü'); - docx = docx.replace(new RegExp('\ö\;', 'g'), 'ö'); - docx = docx.replace(new RegExp('\Ä\;', 'g'), 'Ä'); - docx = docx.replace(new RegExp('\Ü\;', 'g'), 'Ü'); - docx = docx.replace(new RegExp('\Ö\;', 'g'), 'Ö'); - docx = docx.replace(new RegExp('\ß\;', 'g'), 'ß'); - docx = docx.replace(new RegExp('\ \;', 'g'), ' '); - docx = docx.replace(new RegExp('\§\;', 'g'), '§'); - - // remove all entities except gt, lt and amp - var rEntity = /\&(?!gt|lt|amp)\w+\;/g, matchEntry, indexes = []; - while ((matchEntry = rEntity.exec(docx)) !== null) { - indexes.push({ - startId: matchEntry.index, - stopId: matchEntry.index + matchEntry[0].length + }); + convertPromises.push(converter.html2docx(text).then(function (textAsDocx) { + motionData.text = textAsDocx; + })); + convertPromises.push(converter.html2docx(reason).then(function (reasonAsDocx) { + motionData.reason = reasonAsDocx; + })); + $q.all(convertPromises).then(function () { + motionData.comments = comments; + resolve(motionData); + }); }); - } - for (var i = indexes.length - 1; i>=0; i--) { - docx = docx.substring(0, indexes[i].startId) + docx.substring(indexes[i].stopId, docx.length); - } + }); + // resolve, if all motion data is fetched. + return $q(function (resolve) { + $q.all(promises).then(function (data) { + if (data.length) { + // clear pagebreak on last element + data[data.length - 1].pagebreak = ''; + } + resolve(data); + }); + }); + }; - return docx; - }; - var updateRelationships = function (oldContent) { - var content = oldContent.split('\n'); - relationships.forEach(function (rel) { - content[1] += '6q%afN_n(B2#xl~7Y=el1dCM}E z*S-uh*(GEN*+TOUD$7^;uJ8T6@BDM$*E#n&*SYTJd9LRfP_Na1S{Spi@`E@yI6!K) zq)I3^6U$E|i80CiCPvK1eiK)g*xzK0wV3@-Lx40L?%?gY5PpkK3A7XEAy4M`tyq>#Z=@l_Jv}uC%*eT_vYs_I&7`B`$+_wvF4}=Eu&O$D4)u z(hr2KawVZ?DPKx(e|3o3rkzge{KR)Z#@4sCWzP6i(3SM*TK|VJjOCr`r>dmLZ+3}% z?^(Fw36-@^HUU@ZU5-ve&7^o*hI3xn7gA71OY%-ljV&8njzdb?Qtw-EzK~oyXJSlZ zr(p}?`grE2KW!-iKlUp7lrF(O3Gf=l%mg%G_f`m*!Kzqv!wpTt^Yls{=QzT$zY8ig z_3@xr-s@Q_%K|K#ORdh%u1dbEM6^sTfnCm6>00Em7=Ym;{}*kkVw0R`ksy1QvRBhE zCua<;9`hn%6L&`CtObpmiK2O^srdvOz4NVm1@_f;su4%EE44Koq!STIdg35rb}Cgp z)^@|z`$f6?zAu$dDXb^f1fC`9``vB0&qGI;=`Gp5Jt2tvsw;nNTam!3R(Fx3@!9dC zQnBy6fr-ZKu5%ju=~9-(_h$IcRGV!I{cSTsM#WoP?O%_#4Si-6TG}L|rx5N{aN;re z#uszc-g758%+v21mdD8amqh_Q%5vAK0Uv@I#8h_}#4y#}<+uzBOF_=%o17^PI$M2Y z&e{rthxjQQN5>t-9B+V6q0x%z*WN@$*bFMq1IOB@zVx8W106~$zpQ~5l(4BS{q5i% z@%|BlCo4Bn+Kf9m1^tIrZ{|zb8gf|-Err;S8)Womqa}Iss|sh9t17x)WeKG^AeeW> zMfemIbT_FNCTk4ty<(07>222^BX5=>O zu!{!w#*A*ia)II-tQUJjCc`#4|DxW90fX|ESkUO{%;);b8gqzI_lLLdA9SZ>#@)qr zNjqwMAW+jbGqF-y_KpeNqI(dzJpCNpLqut`mG>hR4WT%yk5MFM2*t`br2}Q>85}U~ zzS*k(B#vKJ;vRQoj~D26jUn^`5t7SLIT)PaSZ!k3C&{veI*A6UH>XADj5Estllk$M zAr$YD@gmMFIbt7VwN5c$QfXa{b7&1#E|cgX|Dds?&!eJ~*!_C;&Z$DTTObLjUf7;z z_JxYqcfE@ae82fv3-cn6#YQt%4DQm-l!YhoD!3=CzE-kwJjq=ji6=4YC$GNKw&YRm z?WMlMwBRs_c^9A~V&HR`B`Ik5c%zRvtq}|w5dCTHE-2y8!gX-P&iI6Ui zG($IS>F*KWcHH$;x2NZBcs?yOBWHIt*MXdLv;~`j4Jaa^>(23O1YpUNzP_+rrU91qGm^Ok6FQ`#@YNCj!io<6P~c8R_wa~E-7cZs#(r8c z{jzLMS_fJzfOZ}a&CA=OShX~lP4`z#%-Cd$auF&*P~49Tn?TuoO9mbXnG1^#I5v&L zuX+DyB}()pQZRI-k@EeAEt^di#_Sx{UZM)UY#`8rI0*DV&EhiXpG6Yn|Mt5Xzyu`$ zk5)yVOWS)#5mlz2)Z&r4=+6(znW012I`B*7O6xNAR9I@$dJWU)N`Bu&?d(K<99F)F zLgmR1cq>7-GduHo0EvB;P(P|L7@FEHSq6JfvPh9dRm}uqgt8tcE4M*7RnP^d-4)2k zRcwe^2=)=RpA7-)d1n!*>A^`e%LX80s7IO#NJp40o5wP{crEYf;@{VceX4R4B8F^| zG^rknjeMMHMBvt1>SqsZq!4(OJ3%YxqdO8-9__`ji5&e1smm1D%fP(Ra^lL_p3if1 zBZAq(T2;FuIPm78=5U zOZ|=4HA7U{0)6vU%7Yua%DOv(e3T15$){q&>0-aU<&^oDANLt#q5bs!0^?0*09L_k0XNkL{X+>X^j z5sf~5$(1J$#9tA8wUoc%6Wu$Jd4&KgE9Niw3=%XYl}3B^$g-Fh4LE19!h;aEkfTU@ zjTgv{^ol&!fN6mqJf$bCH~0>KwmrMwj*gr=Ro5(~CQWduQGxic$KleWw$npg5;}Ce zA#7>6S?#Vm8%8UCrESeVh&^wWJ|Lp(m=@q#G3D?c97vvYR>c%F0#P3YlW2X#c~==P z)pF&|tLW7-E$FaLMZTR^U+@porYxR%gz$muOG*;6P=3n!_ja?Vudf51tt#avXp+pd z3sXA0ej#8$;9U`Ug9h|AGW`k$G0%8~h=OpEtaff&o*(#k}^XvnHjQ0QNo=jCZuRES;J>5VNj7J zTMOA~vW3r*k|mSn+8W0FO6Pp1d*6S4zw?~mdEe(ezvp?*TaS8gDCB6z%>xDS^Ya6~ zVU9l)5(aYrz^cPQm0twS1^Y#a+_Aq1i{~+DUz2g5Wj{i9gD6sP?m^?a&>K^g8r5@_ zX+cjD4JV{4&)=-zp9v*DFh}2SixtyJxkHC?mQhfv&MQP4r|f{2(sIKwMu+=nQE^gD z;gN7=pU{`+C)d3V+Rv9!3AFDBk6uQ<>oV=_SsPpE&h2Ngeg^6Yqqf+z3Fkom@=)|1 z2_)jBNzzuIU8T5Om%o>!f9H04CO*n&4SRxQZf2D2$v79?&v5J_`iY>Kqq(w-%b%TXc>s$@7UOgW zZ~FJJ#Vn$!lu^Tx&$4Yf8|!?`%ngWh57a}%S70Qw@~zSTSSxD$&EDRs0c_Totc1p@a=3kkpAczEU6gs_ z-WcBo!{BFvVcZ|ike2-LCc!gh;}YlkVM9nx1@6Ha4_fRe^Z1L1hp}2b$+7aedpZuD zx_SQ53!GZ7`jUJ65<=V-K>u70j&H0GwrZhV2|=m1NnDW^)Hj)`7{3Z;+eo(R7*|Co zIO{2hsX1TW%o#?Mp64skb-~q9D7Q*yq3(PI6}DfRYrEGCLd6zR2DFa+-SFg)7PnH$ z!dK!H4c216=q>uEQ6S5!WMIkuGrkxQxJjdCaSAhgg~@C0i42uXa_sWW!hYj6ziQ8t z>}3Ov&89l5e|L|hERwS!4E7iouLhMc145?CCLkLQX?^H0E!ctfiDXqu@HkvLr*2GF>g5@H+1_JS&K&dZo|&vL-qMg3 zSV9X)c<~GjDnT^bpT>3L4uX&|29MroN34dJ`e0vX1Sls}S?jW&R?z?%4hyL_+)$RxC<@x{Py>n1HnRs^d4 z)a(~0`)BO?)~;?JLvm@ET!Rq==NZM}KP_M~fgSl(@RqIkz zutJmLDmkTG#iCUzg3SC1!K}$>$XE5Y!y9%B!r8)(b|8LLSR0&jnHK<%lm!6(vC*qY zP+qQ|>zW=YiJ)A1ts!Ey#kter1%@Ey?#L9H3`#gBXIX?L`wPdHZOf0#ZSO$j9^zal zdvg49P=~5EMngvhEC&11h=!eLzO#l815c;)F9tRuaQ3g&m1dhGu0o%cw04F$}*nt7RX8VdObgs#R>(^eKgGbuO!2u+&<3(oPJjgsCuWc#gk zNR=a1n%&qZ6*knSNUIV|ShnQqoA<=kBVL?>PaD)kzRGP}u7(Qez1Cx+UhW)??o+Ay zdN8osIQMJiY=H-KblM`(VH+5p^XhZqT;aFD>u={p+G`4;I-W)z|1ilL!%k*Kwt2XT z-m>48Z$z32T7x2{Sy%l`83mRZTimN`aEed$qPB>{V z6g5+#r_yNs6OZ#9S*5T%1(a@z7WW`fxY2wp9iB1L$@%lSnUxs*frhetW`%7wrr0;7 z(Pw*Phcf(x=6||+NnksJO0vE<7xaN{Dt5@#CDpxUCHka3WgbB&H6~e=yR!=`c~=^! zTXjir!<6>_wphO~hWl#FtHD3SZ!SxqT|;jLj$GSW73kSeOnljnsS{+ci|l=;wdwt| zz;s?`-t&hAk