diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index eff7421..285cc08 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,6 +8,7 @@ use OCA\Epubviewer\Listener\BeforeTemplateRenderedListener; use OCA\Epubviewer\Listener\FilesLoadAdditionalScriptsListener; use OCA\Epubviewer\Listener\PublicShareBeforeTemplateRenderedListener; +use OCA\Epubviewer\Preview\EPubPreview; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; @@ -27,6 +28,8 @@ public function __construct() { public function register(IRegistrationContext $context): void { include_once __DIR__ . '/../../vendor/autoload.php'; + $this->registerProvider($context); + // “Emitted before the rendering step of each TemplateResponse. The event holds a flag that specifies if a user is logged in.” // See: https://docs.nextcloud.com/server/latest/developer_manual/basics/events.html#oca-settings-events-beforetemplaterenderedevent $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); @@ -44,6 +47,10 @@ public function register(IRegistrationContext $context): void { // Hooks::register(); } + private function registerProvider(IRegistrationContext $context): void { + $context->registerPreviewProvider(EPubPreview::class, '/^application\/epub\+zip$/'); + } + public function boot(IBootContext $context): void { } } diff --git a/lib/Preview/EPubPreview.php b/lib/Preview/EPubPreview.php new file mode 100644 index 0000000..73ab740 --- /dev/null +++ b/lib/Preview/EPubPreview.php @@ -0,0 +1,248 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Epubviewer\Preview; + +//.epub +use OC\Archive\ZIP; +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\IImage; +use OCP\Preview\IProviderV2; +use OCP\ITempManager; + +class EPubPreview implements IProviderV2 { + private ?ZIP $zip = null; + + /** + * {@inheritDoc} + */ + public function getMimeType(): string { + return '/application\/epub\+zip/'; + } + + /** + * Check if a preview can be generated for $path + * + * {@inheritDoc} + */ + public function isAvailable(FileInfo $file): bool { + return true; + } + + /** + * @inheritDoc + */ + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + $image = $this->extractThumbnail($file, ''); + if ($image && $image->valid()) { + return $image; + } + return null; + } + + /** + * extractThumbnail from complicated epub format + */ + private function extractThumbnail(File $file, string $path): ?IImage { + $tmpManager = \OC::$server->get(ITempManager::class); + $sourceTmp = $tmpManager->getTemporaryFile(); + + try { + $content = $file->fopen('r'); + file_put_contents($sourceTmp, $content); + + $this->zip = new ZIP($sourceTmp); + + $img_data = null; + $contentPath = $this->getContentPath(); + if ($contentPath) { + $package = $this->extractXML($contentPath); + if ($package) { + $path = $contentPath; + $img_src = $cover = null; + // Try first through + $items = $package->manifest->children(); + foreach($items as $item) { + if (($item['id'] == 'cover' || $item['id'] == 'cover-image') && preg_match('/image\//', (string) $item['media-type'])) { + $img_src = (string) $item['href']; + break; + } + } + + // in references + if (!$img_src) { + $references = $package->guide->children(); + foreach($references as $reference) { + if ($reference['type'] == 'cover' || $reference['type'] == 'title-page') { + $cover = (string) $reference['href']; + break; + } + } + } + + // no cover ? no image ? take the first page + if (!$img_src && !$cover) { + $first_page_id = (string) $package->spine->itemref['idref']; + if ($first_page_id) { + foreach($items as $item) { + if ($item['id'] == $first_page_id) { + $cover = (string) $item['href']; + break; + } + } + + } + } + + // have we a "cover" file ? + if ($cover) { + // relative to container + $img_src = null; + $path = $this->resolvePath($path, $cover); + $dom = $this->extractHTML($path); + if ($dom) { + // search img + $images = $dom->getElementsByTagName('img'); + if ($images->length) { + $img_src = $images[0]->getAttribute('src'); + } else { + $images = $dom->getElementsByTagName('image'); + if ($images->length) { + $img_src = $images[0]->getAttribute('xlink:href'); + } + } + } + }// cover + + // img ? + if ($img_src) { + $img_src = $this->resolvePath($path, $img_src); + $img_data = $this->extractFileData($img_src); + } + } + } + + // Pfff. Make a pause + if ($img_data) { + $image = new \OC_Image(); + $image->loadFromData($img_data); + return $image; + } + return null; + } catch (\Exception $e) { + return null; + } + } + + /** + * find the main content XML (usually "content.opf") + */ + private function getContentPath() : ?string { + $xml_container = $this->extractXML('META-INF/container.xml'); + if (is_object($xml_container)) { + $full_path = $xml_container->rootfiles->rootfile['full-path'][0]; + if ($full_path) { + return $full_path->__toString(); + } + } + return null; + } + + /** + * extract HTML from Zip path + * @param string $path + * @return \DOMDocument|null + */ + protected function extractHTML(string $path): \DOMDocument|null { + $html = $this->extractFileData($path); + if (is_string($html)) { + $dom = new \DOMDocument('1.0', 'utf-8'); + $dom->strictErrorChecking = false; + if (@$dom->loadHTML($html)) { + return $dom; + } + } + return null; + } + + /** + * extract XML from Zip path + * + * @psalm-param 'META-INF/container.xml' $path + */ + private function extractXML(string $path): \SimpleXMLElement|false|null { + $xml = $this->extractFileData($path); + if (is_string($xml)) { + return simplexml_load_string($xml); + } + return null; + } + + /** + * get unzipped data + * + * @param string $path file path in zip + * + * @psalm-param 'META-INF/container.xml' $path + * + * @return false|null|string + */ + private function extractFileData(string $path): string|false|null { + if ($this->zip === null) { + return null; + } + $fp = $this->zip->getStream($path, 'r'); + if ($fp) { + $content = stream_get_contents($fp); + fclose($fp); + return $content; + } + return null; + } + + /** + * Resolve relative $relPath from $path (removes ./, ../) + * + * @param string $path reference path + * @param string $relPath relative path + * @return string + */ + private function resolvePath(string $path, string $relPath): string { + $path = dirname($path).'/'.$relPath; + $pieces = explode('/', $path); + $parents = []; + foreach($pieces as $dir) { + switch($dir) { + case '.': + // Don't need to do anything here + break; + case '..': + array_pop($parents); + break; + default: + $parents[] = $dir; + break; + } + } + return implode('/', $parents); + } +}