From ba9725fd46cf71f4d7d909d9dc360715cc2bf45a Mon Sep 17 00:00:00 2001 From: Cameron Otsuka Date: Tue, 11 Aug 2026 12:25:34 -0700 Subject: [PATCH] restore epub 2 support --- README.md | 10 +- .../java/com/alexandria/reader/EpubParser.kt | 179 +++++++++++++++--- scripts/verify-running.ps1 | 19 +- .../fixtures/epub2-src/META-INF/container.xml | 6 + tests/fixtures/epub2-src/OEBPS/chapter1.xhtml | 13 ++ tests/fixtures/epub2-src/OEBPS/chapter2.xhtml | 11 ++ tests/fixtures/epub2-src/OEBPS/cover.png | Bin 0 -> 177 bytes tests/fixtures/epub2-src/OEBPS/package.opf | 21 ++ tests/fixtures/epub2-src/OEBPS/style.css | 5 + tests/fixtures/epub2-src/OEBPS/toc.ncx | 24 +++ tests/fixtures/epub2-src/mimetype | 1 + tests/fixtures/epub2.epub | Bin 0 -> 2711 bytes 12 files changed, 254 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/epub2-src/META-INF/container.xml create mode 100644 tests/fixtures/epub2-src/OEBPS/chapter1.xhtml create mode 100644 tests/fixtures/epub2-src/OEBPS/chapter2.xhtml create mode 100644 tests/fixtures/epub2-src/OEBPS/cover.png create mode 100644 tests/fixtures/epub2-src/OEBPS/package.opf create mode 100644 tests/fixtures/epub2-src/OEBPS/style.css create mode 100644 tests/fixtures/epub2-src/OEBPS/toc.ncx create mode 100644 tests/fixtures/epub2-src/mimetype create mode 100644 tests/fixtures/epub2.epub diff --git a/README.md b/README.md index 8c0434c..f0115ed 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ The app requests no network or shared-storage permission. It imports each select ## EPUB support -- EPUB 3 package, spine, metadata, cover, and navigation documents -- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and break rules +- EPUB 2 and EPUB 3 package, spine, metadata, cover, NCX, and navigation documents +- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and page-break rules - Accurate reflow with browser-grade shaping, bidirectional text, hyphenation, and accessibility text - Paginated and continuous reading modes - Pre-paginated EPUB detection through standard rendition metadata @@ -48,6 +48,12 @@ Start the supplied Android virtual device, then run: The script performs a clean install, imports `tests/fixtures/odyssey.epub`, waits for a measured page count, checks Logcat for crashes, and writes `verification/odyssey-page-1.png`. +Use the EPUB 2 fixture to verify OPF 2 metadata, cover extraction, NCX navigation, and page-break rules: + +```powershell +.\scripts\verify-running.ps1 -Fixture .\tests\fixtures\epub2.epub -OutputDirectory .\verification\epub2 +``` + Run the focused reader tests separately: ```powershell diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt index e78c001..f1c7601 100644 --- a/app/src/main/java/com/alexandria/reader/EpubParser.kt +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -13,7 +13,7 @@ import java.util.Locale import java.util.zip.ZipFile import kotlin.math.roundToInt -/** EPUB 3 package reader. It keeps the original resources intact for standards-based rendering. */ +/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */ object EpubParser { private data class ManifestItem(val href: String, val mediaType: String, val properties: Set) private data class ParsedSpineItem(val item: SpineItem, val linear: Boolean, val fixedLayout: Boolean) @@ -37,8 +37,9 @@ object EpubParser { val opf = parseXml(packageFile) val packageElement = opf.allByLocalName("package").firstOrNull() ?: error("The EPUB package document has no package element.") - require(packageElement.attr("version").substringBefore('.').toIntOrNull() == 3) { - "Only EPUB 3 publications are supported." + val packageVersion = packageElement.attr("version").substringBefore('.').toIntOrNull() + require(packageVersion == 2 || packageVersion == 3) { + "Only EPUB 2 and EPUB 3 publications are supported." } val packageDirectory = packagePath.substringBeforeLast('/', "") @@ -50,19 +51,22 @@ object EpubParser { metadata.firstText("language")?.let { book.language = it } metadata.firstText("publisher")?.let { book.publisher = it } metadata.firstText("description")?.let { book.description = it } - val packageViewport = metadata.allByLocalName("meta").firstNotNullOfOrNull { meta -> - if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) { - return@firstNotNullOfOrNull null + val packageViewport = if (packageVersion == 3) { + metadata.allByLocalName("meta").firstNotNullOfOrNull { meta -> + if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) { + return@firstNotNullOfOrNull null + } + val dimensions = Regex( + "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", + RegexOption.IGNORE_CASE, + ).find(meta.text()) ?: return@firstNotNullOfOrNull null + val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null + val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null + (width to height).takeIf { width > 0 && height > 0 } } - val dimensions = Regex( - "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", - RegexOption.IGNORE_CASE, - ).find(meta.text()) ?: return@firstNotNullOfOrNull null - val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null - val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null - (width to height).takeIf { width > 0 && height > 0 } - } ?: (1200 to 1600) - val packageFixedLayout = metadata.allByLocalName("meta").any { meta -> + } else null + val pageSize = packageViewport ?: (1200 to 1600) + val packageFixedLayout = packageVersion == 3 && metadata.allByLocalName("meta").any { meta -> meta.attr("property").equals("rendition:layout", ignoreCase = true) && meta.text().trim().equals("pre-paginated", ignoreCase = true) } @@ -94,23 +98,56 @@ object EpubParser { ParsedSpineItem( item = SpineItem(item.href, chapterTitle(chapterFile)), linear = !reference.attr("linear").equals("no", ignoreCase = true), - fixedLayout = "rendition:layout-pre-paginated" in item.properties || - "rendition:layout-pre-paginated" in referenceProperties, + fixedLayout = packageVersion == 3 && + ("rendition:layout-pre-paginated" in item.properties || + "rendition:layout-pre-paginated" in referenceProperties), ) } require(parsedSpine.isNotEmpty()) { "The EPUB reading order is empty." } val spine = parsedSpine.map(ParsedSpineItem::item) - manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item -> - val candidate = safeFile(output, item.href) - if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath + if (packageVersion == 3) { + manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item -> + val candidate = safeFile(output, item.href) + if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath + } + } else { + val coverId = metadata.allByLocalName("meta").firstOrNull { + it.attr("name").equals("cover", ignoreCase = true) + }?.attr("content")?.trim() + val metadataCover = coverId?.let(manifest::get)?.takeIf { it.mediaType.startsWith("image/") } + val guideCover = opf.allByLocalName("guide").firstOrNull()?.children() + ?.firstOrNull { reference -> + reference.localName() == "reference" && + reference.attr("type").equals("cover", ignoreCase = true) + }?.attr("href")?.takeIf(String::isNotBlank)?.let { href -> + val coverPath = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')) + manifest.values.firstOrNull { it.href == coverPath } + } + setEpub2Cover(output, metadataCover ?: guideCover, book) } - val navItem = manifest.values.singleOrNull { "nav" in it.properties } - ?: error("The EPUB manifest must identify one navigation document.") - require(navItem.mediaType == "application/xhtml+xml") { "The EPUB navigation document must be XHTML." } - val toc = parseHtmlToc(output, navItem.href, spine) - require(toc.isNotEmpty()) { "The EPUB navigation document has no table of contents." } + val toc = if (packageVersion == 3) { + val navItem = manifest.values.singleOrNull { "nav" in it.properties } + ?: error("The EPUB 3 manifest must identify one navigation document.") + require(navItem.mediaType == "application/xhtml+xml") { + "The EPUB navigation document must be XHTML." + } + parseHtmlToc(output, navItem.href, spine).also { + require(it.isNotEmpty()) { "The EPUB navigation document has no table of contents." } + } + } else { + val ncxId = spineElement.attr("toc") + require(ncxId.isNotBlank()) { "The EPUB 2 spine does not identify an NCX document." } + val ncxItem = manifest[ncxId] + ?: error("The EPUB 2 spine references an NCX document that is not in the manifest.") + require(ncxItem.mediaType == "application/x-dtbncx+xml") { + "The EPUB 2 table of contents must be an NCX document." + } + parseNcxToc(output, ncxItem.href, spine).also { + require(it.isNotEmpty()) { "The EPUB NCX document has no table of contents." } + } + } val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT) .takeIf { it == "rtl" || it == "ltr" } ?: "ltr" @@ -124,8 +161,8 @@ object EpubParser { toc = toc, readingDirection = direction, fixedLayout = fixedLayout, - defaultPageWidth = packageViewport.first, - defaultPageHeight = packageViewport.second, + defaultPageWidth = pageSize.first, + defaultPageHeight = pageSize.second, ) } @@ -158,6 +195,31 @@ object EpubParser { } } + private fun setEpub2Cover(root: File, item: ManifestItem?, book: LibraryBook) { + if (item == null) return + val candidate = safeFile(root, item.href) + if (!candidate.isFile) return + if (item.mediaType.startsWith("image/")) { + book.coverPath = candidate.absolutePath + return + } + if (!isContentDocument(item.mediaType)) return + + val document = parseXml(candidate) + val image = document.getAllElements().firstOrNull { element -> + element.localName() == "img" || element.localName() == "image" + } ?: return + val source = image.attr("src").ifBlank { + image.attributes().asList().firstOrNull { attribute -> + attribute.key.substringAfter(':').equals("href", ignoreCase = true) + }?.value.orEmpty() + } + if (source.isBlank()) return + val directory = item.href.substringBeforeLast('/', "") + val imagePath = normalizePath(directory, source.substringBefore('#').substringBefore('?')) + safeFile(root, imagePath).takeIf(File::isFile)?.let { book.coverPath = it.absolutePath } + } + private fun parseHtmlToc(root: File, path: String, spine: List): List { val file = safeFile(root, path) if (!file.isFile) return emptyList() @@ -186,6 +248,35 @@ object EpubParser { return result } + private fun parseNcxToc(root: File, path: String, spine: List): List { + val file = safeFile(root, path) + require(file.isFile) { "The EPUB NCX document is missing." } + val document = parseXml(file) + val navMap = document.allByLocalName("navmap").firstOrNull() + ?: error("The EPUB NCX document has no navigation map.") + val result = mutableListOf() + val directory = path.substringBeforeLast('/', "") + + fun walk(parent: Element, depth: Int) { + parent.children().filter { it.localName() == "navpoint" }.forEach { point -> + val navLabel = point.children().firstOrNull { it.localName() == "navlabel" } + ?: error("An EPUB NCX navigation point has no label.") + val label = navLabel.allByLocalName("text").firstOrNull()?.text()?.trim().orEmpty() + require(label.isNotBlank()) { "An EPUB NCX navigation point has an empty label." } + val content = point.children().firstOrNull { it.localName() == "content" } + ?: error("An EPUB NCX navigation point has no content target.") + val href = content.attr("src") + require(href.isNotBlank()) { "An EPUB NCX navigation point has an empty content target." } + val resolved = resolveReference(directory, href) + result += TocEntry(label, resolved, depth, spineIndex(spine, resolved)) + walk(point, depth + 1) + } + } + + walk(navMap, 0) + return result + } + private fun spineIndex(spine: List, reference: String): Int { val path = reference.substringBefore('#') return spine.indexOfFirst { it.href == path }.also { index -> @@ -344,7 +435,10 @@ object EpubHtmlBuilder { val value = attribute.value when { key.startsWith("on") -> element.removeAttr(attribute.key) - key == "style" -> element.attr(attribute.key, rewriteCssUrls(value, chapter.directory, publication.rootDirectory)) + key == "style" -> element.attr( + attribute.key, + rewriteCssUrls(rewritePageBreaks(value), chapter.directory, publication.rootDirectory), + ) key == "srcset" -> element.attr(attribute.key, rewriteSrcSet(value, chapter.directory, publication.rootDirectory)) key in setOf("src", "poster", "data") || key.endsWith(":href") -> { val lower = value.trim().lowercase(Locale.ROOT) @@ -1161,7 +1255,7 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT val body = source.substring(open + 1, close) val trimmed = prelude.trimStart() fun appendRule(rulePrelude: String, declarations: String) { - out.append(transformSelector(rulePrelude)).append('{').append(declarations).append('}') + out.append(transformSelector(rulePrelude)).append('{').append(rewritePageBreaks(declarations)).append('}') } when { trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || trimmed.startsWith("@layer", true) -> @@ -1183,6 +1277,33 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT return process(css) } + private fun rewritePageBreaks(css: String): String { + val edgeRule = Regex( + "page-break-(before|after)\\s*:\\s*(always|avoid|auto|left|right|inherit|initial|unset)(\\s*!important)?\\s*;?", + RegexOption.IGNORE_CASE, + ) + val rewritten = edgeRule.replace(css) { match -> + val value = when (match.groupValues[2].lowercase(Locale.ROOT)) { + "always", "left", "right" -> "column" + "avoid" -> "avoid-column" + else -> match.groupValues[2].lowercase(Locale.ROOT) + } + "break-${match.groupValues[1].lowercase(Locale.ROOT)}:$value${match.groupValues[3]};" + } + val insideRule = Regex( + "page-break-inside\\s*:\\s*(avoid|auto|inherit|initial|unset)(\\s*!important)?\\s*;?", + RegexOption.IGNORE_CASE, + ) + return insideRule.replace(rewritten) { match -> + val value = if (match.groupValues[1].equals("avoid", ignoreCase = true)) { + "avoid-column" + } else { + match.groupValues[1].lowercase(Locale.ROOT) + } + "break-inside:$value${match.groupValues[2]};" + } + } + private fun findCssDelimiter(value: String, start: Int, delimiter: Char): Int { var quote = '\u0000'; var comment = false; var index = start while (index < value.length) { diff --git a/scripts/verify-running.ps1 b/scripts/verify-running.ps1 index 354b7de..9da7bac 100644 --- a/scripts/verify-running.ps1 +++ b/scripts/verify-running.ps1 @@ -51,10 +51,21 @@ try { } if (-not $ready) { throw "The fixture did not reach a rendered first page." } - & $adb -s $serial shell screencap -p /sdcard/alexandria-odyssey.png - $screenshot = Join-Path $OutputDirectory "odyssey-page-1.png" - & $adb -s $serial pull /sdcard/alexandria-odyssey.png $screenshot | Out-Null - if ((Get-Item $screenshot).Length -lt 100000) { throw "The verification screenshot is unexpectedly small." } + & $adb -s $serial shell screencap -p /sdcard/alexandria-reader.png + $fixtureName = [System.IO.Path]::GetFileNameWithoutExtension($Fixture) + $screenshot = Join-Path $OutputDirectory "$fixtureName-page-1.png" + & $adb -s $serial pull /sdcard/alexandria-reader.png $screenshot | Out-Null + if ((Get-Item $screenshot).Length -lt 10000) { throw "The verification screenshot is incomplete." } + Add-Type -AssemblyName System.Drawing + $image = [System.Drawing.Image]::FromFile((Resolve-Path $screenshot)) + try { + if ($image.Width -lt 1000 -or $image.Height -lt 1000) { + throw "The verification screenshot has unexpected dimensions: $($image.Width)x$($image.Height)." + } + } + finally { + $image.Dispose() + } $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader" if ($fatal) { throw "Alexandria crashed during verification.`n$($fatal -join "`n")" } diff --git a/tests/fixtures/epub2-src/META-INF/container.xml b/tests/fixtures/epub2-src/META-INF/container.xml new file mode 100644 index 0000000..fe5cbeb --- /dev/null +++ b/tests/fixtures/epub2-src/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml new file mode 100644 index 0000000..890fb29 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml @@ -0,0 +1,13 @@ + + + + EPUB 2 Start + + + +

EPUB 2 Start

+

This publication verifies OPF 2 metadata, reading order, and NCX navigation.

+

Nested Section

+

This section uses EPUB 2 page-break properties and links to the second chapter.

+ + diff --git a/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml new file mode 100644 index 0000000..9aeef34 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml @@ -0,0 +1,11 @@ + + + + Second Chapter + + + +

Second Chapter

+

The EPUB 2 NCX points to this chapter.

+ + diff --git a/tests/fixtures/epub2-src/OEBPS/cover.png b/tests/fixtures/epub2-src/OEBPS/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..1dbf0821b0594c24805755266f41577c08bc169f GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0y~yV5neVU|7Myz`(%3XIQET67Y0!45_&F_Ku?CKyQYur(iiAY;SK*nBYILu7<*)4_xd zvAk@}xq2YE)vg9No)sCv6hhUbQ$Jj9BpVbk-0Nq)Xm9AhWhuxlp00i_>zopr0NO@4 AR{#J2 literal 0 HcmV?d00001 diff --git a/tests/fixtures/epub2-src/OEBPS/package.opf b/tests/fixtures/epub2-src/OEBPS/package.opf new file mode 100644 index 0000000..2b6f2ec --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/package.opf @@ -0,0 +1,21 @@ + + + + urn:uuid:alexandria-epub2-verification + EPUB 2 Verification + Alexandria Tests + en + + + + + + + + + + + + + + diff --git a/tests/fixtures/epub2-src/OEBPS/style.css b/tests/fixtures/epub2-src/OEBPS/style.css new file mode 100644 index 0000000..69737b3 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/style.css @@ -0,0 +1,5 @@ +html, body { margin: 0; padding: 0; } +body { font-family: serif; font-size: 24px; line-height: 1.45; } +h1 { font-size: 2em; } +h2 { page-break-before: always; } +.keep { page-break-inside: avoid; } diff --git a/tests/fixtures/epub2-src/OEBPS/toc.ncx b/tests/fixtures/epub2-src/OEBPS/toc.ncx new file mode 100644 index 0000000..13a904c --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/toc.ncx @@ -0,0 +1,24 @@ + + + + + + + + + EPUB 2 Verification + + + EPUB 2 Start + + + Nested Section + + + + + Second Chapter + + + + diff --git a/tests/fixtures/epub2-src/mimetype b/tests/fixtures/epub2-src/mimetype new file mode 100644 index 0000000..57ef03f --- /dev/null +++ b/tests/fixtures/epub2-src/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/tests/fixtures/epub2.epub b/tests/fixtures/epub2.epub new file mode 100644 index 0000000000000000000000000000000000000000..d215f7f9ebee2d1ae3729b979bbfa0b2b78739b6 GIT binary patch literal 2711 zcmWIWW@Zs#0D-(h?%4dq)jA*^2y-wnFyv8BQyCTUk? z7Ql3JFo1LhXMK9Go`Hek4+8^(7y|=?uWN{-uBV@yesX?ZNn&PRYLQ+=Zcc2UW7c5< z0oU(#tut~9uDog5AfvJ6Hp?uz1f~99!Av)gqQX3RsV2i%yNW;GcORB%{Y?vHH{U(HFyBf&=A!Y_`gbzS;@rV$$`Lh&JrlcDp7T(WJK{TW$E@XP z(*$2gFWsdRnCR<7L=qG z8R}JJl;q~b2KxFlD+=uW9`5rYOMG|Sn#9aS8;@r#Qj1%DxFlc=T2;hGlNR?~J_1!DV0byJLGRF2!FH_1ItizA8oB z+4hdCjA+IG8&2HnNhT*M_-AeiV=$CH^}XTXfp=fondaR!h>}*lyk#wGx|QGxgPExd z_ib`|e@xPDd8FrUKgk8(BfQl6Ts(am)A;|0X&I>dTU;ob&)Gkr$Y;vVWorzx0$DAX zqM2HYRrek}GUayKpH}N*HUiwSEBX%}IHT2Bb!zRCE7K;0t`o0E4f((J=Re$HU|_Ih z#1isGkdRO9cl17DAkg~$ujt`BzH8#%OiDTE+OM>OJ1*P$Hp^udmFC?4dsAMYeLegA z-u}SjrQbd{@7y6_vh9&v;~@ceakbv*pI_e3&am?u_7rTYO*-ZEE^}|S_&&wS*CQ%Ehv*0@ zf0*W>eEC$72J6AJUH=3Zv0XCpddK%w^!Qt|)aZi6Ztp(3^!%T*ZExVoIl2c=|9&bd z_u%apPR(5hK;aXfaHDcz%Bo4Vu_u2m%dBHWghhEFckJvfip|p)7#KD(Ffi~lFfc$< zVSZU^kzPSw`fHvK#m}EkdX$uq@Zjs00?wp_gcS)12@wYm9yoB|KzhU~4xtYvg*+>F zeErt9SfQDT|1+Dv`@;Hl+!C$cDR(xDe9MomFFh`@tZLcU>r#Ab5*{Bmn9A2EBp*I= zqsHK&b4p9z<%2D}8~5a#-C3ou^XfV_^Le6@2Spw&sM~SR!%QP#$My3y7w=uDKOHn7 z+9vjI%r#3@6GjGv7eHl2gwoy{OBop$jxaMY2*SNkkeHmEn4YSaUywF+vSa=nP+b1j z)`?gCaA>J0(;~O5^R_B`tKTxZ8E(sNi~aOoo9FZ_?;}*ub zYb&P7Su(Gl<90jj&(ojopQhShYro|ERV#Xj=I2Pus6Vz&O;S3E%>r6=Mej@<&)&Qu zZ5^^l`BSUF^wX-YZ)aa=4SRC@vG~K9!>^f>Q~6c4s_eAjT5C30zPXcaEsMm9gO?OG znFwDz5wS9~@O0XW{EuHHat!-caAzh=3S*zb`Qzgh^>mIK3nsOkoA!vW@UYON#$UhB zPgkF|IsUz@ce(IthDk9i_U8t^kz|uv^zGK2D{i;Srs!VTaA4c#MKi7+T=Qn(qf>Jg z3zp4M-LPez@S9Zbm?L*PUw@kB>($N6vt`nR1^wO|vmUMCz7R5Jj@RRj?7c<{&G|*A z-qFk3+I;b>#wc=1iS8XIX?hcmBF~ z+tXiQQcm~#Bt`G#eN~@N#uWdScV2tukF4>3|1`~S`~iG&x&}pAl?}034Q&hWxvSp1 zyY-*aiS?3#(ehp!>rVdqz>XTH1`I-y_SRd?N zK7%iL+I%`WJoOK&_AOmUVEKPyW8oyzJ{cuHq2UvJW$O**Uj!3TYiZDD(Yy z(KoBM(2Or{R<$_I{QBiA*M5&f2Fr4pjyJZbnw3qxQ2uzY?S{YkbwQupZ4U5p@^)GZ z>m{kl8hbi?KN2|EGv=tz4^M@1#66q1sRUoUeW_-MdNc_S;K$4}Q5k+jmAo(xd3v^NP=} zU%ql*URTT-!;o{qO2-dR6!AC~?Y`P&pUr*O>7T4*GmOq|*OKc!w|Jw7@5JD#Cwg|y z(b{@5r%f#8tKFBxrFLrJ)5TYXY8mxTdg;J1SMf%fVvF7cM*)!;9;Z)dzFi}7Ojx;4 z>9xbZ!(p%&+~W8;^Xryx{7)wp&%7-&h&(2RBqKiel^&fjmd{jRtSsJ`3_m5nx^@_u{ua)ijzxCLU zhPt9n$5^HO!_Mr>`LAtcr}If*&F#|swYT*r@!2s3cr!AIfNNI}*1fbh0P z5D9CZAar4GjDXaF@U})PoJ|vSGtg^$kbV%})+oyeFLhDsesmMiYb}s&5Z=}}oe9kZ zBS;;FYy!5j9;6$Dw>2(ihMNE@`H?kaD`i0%L3mqZ8VgJ_Qkjdc9X&&Xw1V)qMk_X$ qc6jzi*NmRCKpH`KTjM*pW@tVO@MdKLNpUc6FnnTUVCV<6I~f4n_&