diff options
author | Harald Eilertsen <haraldei@anduin.net> | 2024-11-07 19:23:35 +0100 |
---|---|---|
committer | Harald Eilertsen <haraldei@anduin.net> | 2024-11-08 16:43:29 +0100 |
commit | fe30b5497593dcfb4445d72c99fa357011cebf46 (patch) | |
tree | 6d4d89fed5dcd52e26dd1e07e7cae04206c737d4 /vendor/mikespub/php-epub-meta/src | |
parent | b00ae997a5dab923a99e1f1cccf35bb52eba9a62 (diff) | |
download | volse-hubzilla-fe30b5497593dcfb4445d72c99fa357011cebf46.tar.gz volse-hubzilla-fe30b5497593dcfb4445d72c99fa357011cebf46.tar.bz2 volse-hubzilla-fe30b5497593dcfb4445d72c99fa357011cebf46.zip |
Update php-epub-meta and use composer for dep handling.
Note that we upgrade to the 2.x branch of the dependency, as the 3.x
branch requires PHP version 8.2 or later. There's no reason for us to
move our minimum supported version of PHP just yet.
Diffstat (limited to 'vendor/mikespub/php-epub-meta/src')
18 files changed, 4823 insertions, 0 deletions
diff --git a/vendor/mikespub/php-epub-meta/src/App/Handler.php b/vendor/mikespub/php-epub-meta/src/App/Handler.php new file mode 100644 index 000000000..f1f7547ec --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/App/Handler.php @@ -0,0 +1,236 @@ +<?php +/** + * PHP EPub Meta - App request handler + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Sébastien Lucas <sebastien@slucas.fr> + * @author Simon Schrape <simon@epubli.com> © 2015 + * @author mikespub + */ + +namespace SebLucas\EPubMeta\App; + +use SebLucas\EPubMeta\EPub; +use SebLucas\EPubMeta\Tools\ZipEdit; +use Exception; + +class Handler +{ + protected string $bookdir; + protected string $rootdir; + protected ?EPub $epub; + protected ?string $error; + + public function __construct(string $bookdir) + { + $this->bookdir = $bookdir; + $this->rootdir = dirname(__DIR__, 2); + } + + /** + * Handle request + * @param mixed $request @todo + * @return void + */ + public function handle($request = null) + { + // proxy google requests + if (isset($_GET['api'])) { + header('application/json; charset=UTF-8'); + echo $this->searchBookApi($_GET['api']); + return; + } + if (!empty($_REQUEST['book'])) { + try { + $book = preg_replace('/[^\w ._-]+/', '', $_REQUEST['book']); + $book = basename($book . '.epub'); // no upper dirs, lowers might be supported later + $this->epub = new EPub($this->bookdir . $book, ZipEdit::class); + } catch (Exception $e) { + $this->error = $e->getMessage(); + } + } + // return image data + if (!empty($_REQUEST['img']) && isset($this->epub)) { + $img = $this->epub->getCoverInfo(); + header('Content-Type: ' . $img['mime']); + echo $img['data']; + return; + } + // save epub data + if (isset($_REQUEST['save']) && isset($this->epub)) { + $this->epub = $this->saveEpubData($this->epub); + if (!$this->error) { + // rename + $new = $this->renameEpubFile($this->epub); + $go = basename($new, '.epub'); + header('Location: ?book=' . rawurlencode($go)); + return; + } + } + $data = []; + $data['bookdir'] = htmlspecialchars($this->bookdir); + $data['booklist'] = ''; + $list = glob($this->bookdir . '/*.epub'); + foreach ($list as $book) { + $base = basename($book, '.epub'); + $name = Util::book_output($base); + $data['booklist'] .= '<li ' . ($base == $_REQUEST['book'] ? 'class="active"' : '') . '>'; + $data['booklist'] .= '<a href="?book=' . htmlspecialchars($base) . '">' . $name . '</a>'; + $data['booklist'] .= '</li>'; + } + if (isset($this->error)) { + $data['alert'] = "alert('" . htmlspecialchars($this->error) . "');"; + } + if (empty($this->epub)) { + $data['license'] = str_replace("\n\n", '</p><p>', htmlspecialchars(file_get_contents($this->rootdir . '/LICENSE'))); + $template = $this->rootdir . '/templates/index.html'; + } else { + $data = $this->getEpubData($this->epub, $data); + $template = $this->rootdir . '/templates/epub.html'; + } + header('Content-Type: text/html; charset=utf-8'); + echo $this->renderTemplate($template, $data); + } + + /** + * Proxy google requests + * @param string $query + * @return string|false + */ + protected function searchBookApi($query) + { + return file_get_contents('https://www.googleapis.com/books/v1/volumes?q=' . rawurlencode($query) . '&maxResults=25&printType=books&projection=full'); + } + + /** + * Get Epub data + * @param EPub $epub + * @param array<string, string> $data + * @return array<string, string> + */ + protected function getEpubData($epub, $data = []) + { + $data['book'] = htmlspecialchars($_REQUEST['book']); + $data['title'] = htmlspecialchars($epub->getTitle()); + $data['authors'] = ''; + $count = 0; + foreach ($epub->getAuthors() as $as => $name) { + $data['authors'] .= '<p>'; + $data['authors'] .= '<input type="text" name="authorname[' . $count . ']" value="' . htmlspecialchars($name) . '" />'; + $data['authors'] .= ' (<input type="text" name="authoras[' . $count . ']" value="' . htmlspecialchars($as) . '" />)'; + $data['authors'] .= '</p>'; + $count++; + } + $data['cover'] = '?book=' . htmlspecialchars($_REQUEST['book']) . '&img=1'; + $c = $epub->getCoverInfo(); + $data['imgclass'] = $c['found'] ? 'hasimg' : 'noimg'; + $data['description'] = htmlspecialchars($epub->getDescription()); + $data['subjects'] = htmlspecialchars(join(', ', $epub->getSubjects())); + $data['publisher'] = htmlspecialchars($epub->getPublisher()); + $data['copyright'] = htmlspecialchars($epub->getCopyright()); + $data['language'] = htmlspecialchars($epub->getLanguage()); + $data['isbn'] = htmlspecialchars($epub->getISBN()); + return $data; + } + + /** + * Save Epub data + * @param EPub $epub + * @return EPub + */ + protected function saveEpubData($epub) + { + $epub->setTitle($_POST['title']); + $epub->setDescription($_POST['description']); + $epub->setLanguage($_POST['language']); + $epub->setPublisher($_POST['publisher']); + $epub->setCopyright($_POST['copyright']); + $epub->setIsbn($_POST['isbn']); + $epub->setSubjects($_POST['subjects']); + + $authors = []; + foreach ((array) $_POST['authorname'] as $num => $name) { + if ($name) { + $as = $_POST['authoras'][$num]; + if (!$as) { + $as = $name; + } + $authors[$as] = $name; + } + } + $epub->setAuthors($authors); + + // handle image + $cover = ''; + if (preg_match('/^https?:\/\//i', $_POST['coverurl'])) { + $data = @file_get_contents($_POST['coverurl']); + if ($data) { + $cover = tempnam(sys_get_temp_dir(), 'epubcover'); + file_put_contents($cover, $data); + unset($data); + } + } elseif(is_uploaded_file($_FILES['coverfile']['tmp_name'])) { + $cover = $_FILES['coverfile']['tmp_name']; + } + if ($cover) { + $info = @getimagesize($cover); + if (preg_match('/^image\/(gif|jpe?g|png)$/', $info['mime'])) { + $epub->setCoverInfo($cover, $info['mime']); + } else { + $this->error = 'Not a valid image file' . $cover; + } + } + + // save the ebook + try { + $epub->save(); + } catch (Exception $e) { + $this->error = $e->getMessage(); + } + + // clean up temporary cover file + if ($cover) { + @unlink($cover); + } + + return $epub; + } + + /** + * Rename Epub file + * @param EPub $epub + * @return string + */ + protected function renameEpubFile($epub) + { + $author = array_keys($epub->getAuthors())[0]; + $title = $epub->getTitle(); + $new = Util::to_file($author . '-' . $title); + $new = $this->bookdir . $new . '.epub'; + $old = $epub->file(); + if (realpath($new) != realpath($old)) { + if (!@rename($old, $new)) { + $new = $old; //rename failed, stay here + } + } + return $new; + } + + /** + * Render template with data + * @param string $template + * @param array<string, string> $data + * @return string + */ + protected function renderTemplate($template, $data) + { + if (!file_exists($template)) { + throw new Exception('Invalid template ' . htmlspecialchars($template)); + } + $content = file_get_contents($template); + foreach ($data as $name => $value) { + $content = preg_replace('/{{\s*' . $name . '\s*}}/', $value, $content); + } + return $content; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/App/Util.php b/vendor/mikespub/php-epub-meta/src/App/Util.php new file mode 100644 index 000000000..730e9ec95 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/App/Util.php @@ -0,0 +1,55 @@ +<?php +/** + * PHP EPub Meta utility functions for App interface + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Sébastien Lucas <sebastien@slucas.fr> + * @author Simon Schrape <simon@epubli.com> © 2015 + * @author mikespub + */ + +namespace SebLucas\EPubMeta\App; + +class Util +{ + /** + * Summary of to_file + * @param string $input + * @return string + */ + public static function to_file($input) + { + $input = str_replace(' ', '_', $input); + $input = str_replace('__', '_', $input); + $input = str_replace(',_', ',', $input); + $input = str_replace('_,', ',', $input); + $input = str_replace('-_', '-', $input); + $input = str_replace('_-', '-', $input); + $input = str_replace(',', '__', $input); + return $input; + } + + /** + * Summary of book_output + * @param string $input + * @return string + */ + public static function book_output($input) + { + $input = str_replace('__', ',', $input); + $input = str_replace('_', ' ', $input); + $input = str_replace(',', ', ', $input); + $input = str_replace('-', ' - ', $input); + [$author, $title] = explode('-', $input, 2); + $author = trim($author); + $title = trim($title); + + if (!$title) { + $title = $author; + $author = ''; + } + + return '<span class="title">' . htmlspecialchars($title) . '</span>' . + '<span class="author">' . htmlspecialchars($author) . '</author>'; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Contents/Nav.php b/vendor/mikespub/php-epub-meta/src/Contents/Nav.php new file mode 100644 index 000000000..e8eb980a6 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Contents/Nav.php @@ -0,0 +1,63 @@ +<?php + +namespace SebLucas\EPubMeta\Contents; + +/** + * EPUB NAV structure for EPUB 3 + * + * @author Simon Schrape <simon@epubli.com> + */ +class Nav +{ + /** @var string from main document */ + protected $docTitle; + /** @var string from main document */ + protected $docAuthor; + /** @var NavPointList */ + protected $navMap; + + /** + * Summary of __construct + * @param string $title + * @param string $author + */ + public function __construct($title, $author) + { + $this->docTitle = $title; + $this->docAuthor = $author; + $this->navMap = new NavPointList(); + } + + /** + * @return string + */ + public function getDocTitle() + { + return $this->docTitle; + } + + /** + * @return string + */ + public function getDocAuthor() + { + return $this->docAuthor; + } + + /** + * @return NavPointList + */ + public function getNavMap() + { + return $this->navMap; + } + + /** + * @param string $file + * @return array|NavPoint[] + */ + public function findNavPointsForFile($file) + { + return $this->getNavMap()->findNavPointsForFile($file); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Contents/NavPoint.php b/vendor/mikespub/php-epub-meta/src/Contents/NavPoint.php new file mode 100644 index 000000000..7ffb29dca --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Contents/NavPoint.php @@ -0,0 +1,109 @@ +<?php + +namespace SebLucas\EPubMeta\Contents; + +/** + * An EPUB TOC navigation point. + * + * @author Simon Schrape <simon@epubli.com> + */ +class NavPoint +{ + /** @var string */ + protected $id; + /** @var string */ + protected $class; + /** @var int */ + protected $playOrder; + /** @var string */ + protected $navLabel; + /** @var string */ + protected $contentSourceFile; + /** @var string */ + protected $contentSourceFragment; + /** @var NavPointList */ + protected $children; + + /** + * @param string $id + * @param string $class + * @param int $playOrder + * @param string $label + * @param string $contentSource + */ + public function __construct($id, $class, $playOrder, $label, $contentSource) + { + $this->id = $id; + $this->class = $class; + $this->playOrder = $playOrder; + $this->navLabel = $label; + $contentSourceParts = explode('#', $contentSource, 2); + $this->contentSourceFile = $contentSourceParts[0]; + $this->contentSourceFragment = $contentSourceParts[1] ?? null; + $this->children = new NavPointList(); + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getClass() + { + return $this->class; + } + + /** + * @return int + */ + public function getPlayOrder() + { + return $this->playOrder; + } + + /** + * @return string + */ + public function getNavLabel() + { + return $this->navLabel; + } + + /** + * @return string + */ + public function getContentSource() + { + return $this->contentSourceFile . ($this->contentSourceFragment ? '#' . $this->contentSourceFragment : ''); + } + + /** + * @return string + */ + public function getContentSourceFile() + { + return $this->contentSourceFile; + } + + /** + * @return string + */ + public function getContentSourceFragment() + { + return $this->contentSourceFragment; + } + + /** + * @return NavPointList + */ + public function getChildren() + { + return $this->children; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Contents/NavPointList.php b/vendor/mikespub/php-epub-meta/src/Contents/NavPointList.php new file mode 100644 index 000000000..540d2f876 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Contents/NavPointList.php @@ -0,0 +1,65 @@ +<?php + +namespace SebLucas\EPubMeta\Contents; + +use ArrayIterator; + +/** + * A list of EPUB TOC navigation points. + * + * @author Simon Schrape <simon@epubli.com> + * @author mikespub + * @extends ArrayIterator<int, NavPoint> + */ +class NavPointList extends ArrayIterator +{ + public function __construct() {} + + /** + * @return NavPoint + */ + public function first() + { + $this->rewind(); + return $this->current(); + } + + /** + * @return NavPoint + */ + public function last() + { + $this->seek($this->count() - 1); + return $this->current(); + } + + /** + * @param NavPoint $navPoint + * @return void + * @deprecated 2.1.0 use normal append() instead + */ + public function addNavPoint(NavPoint $navPoint) + { + $this->append($navPoint); + } + + /** + * @param string $file + * + * @return array|NavPoint[] + */ + public function findNavPointsForFile($file) + { + $matches = []; + foreach ($this as $navPoint) { + if ($navPoint->getContentSourceFile() == $file) { + $matches[] = $navPoint; + } + $childMatches = $navPoint->getChildren()->findNavPointsForFile($file); + if (count($childMatches)) { + $matches = array_merge($matches, $childMatches); + } + } + return $matches; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Contents/Spine.php b/vendor/mikespub/php-epub-meta/src/Contents/Spine.php new file mode 100644 index 000000000..e6dfc2a84 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Contents/Spine.php @@ -0,0 +1,198 @@ +<?php + +namespace SebLucas\EPubMeta\Contents; + +use SebLucas\EPubMeta\Data\Item; +use ArrayAccess; +use Countable; +use Iterator; +use BadMethodCallException; + +/** + * EPUB spine structure + * + * @author Simon Schrape <simon@epubli.com> + * @implements \Iterator<int, Item> + * @implements \ArrayAccess<int, Item> + */ +class Spine implements Iterator, Countable, ArrayAccess +{ + /** @var Item */ + protected $tocItem; + protected string $tocFormat; + /** @var array|Item[] The ordered list of all Items in this Spine. */ + protected $items = []; + + /** + * Spine Constructor. + * + * @param Item $tocItem The TOC Item of this Spine. + * @param string $tocFormat The TOC Format of this Spine (Toc or Nav). + */ + public function __construct(Item $tocItem, string $tocFormat) + { + $this->tocItem = $tocItem; + $this->tocFormat = $tocFormat; + } + + /** + * Get the TOC Item of this Spine. + * + * @return Item + */ + public function getTocItem() + { + return $this->tocItem; + } + + /** + * Get the TOC Format of this Spine. + * + * @return string + */ + public function getTocFormat() + { + return $this->tocFormat; + } + + /** + * Append an Item to this Spine. + * + * @param Item $item The Item to append to this Spine. + * @return void + */ + public function appendItem(Item $item) + { + $this->items[] = $item; + } + + /** + * Return the current Item while iterating this Spine. + * + * @link http://php.net/manual/en/iterator.current.php + * @return Item + */ + public function current(): Item + { + return current($this->items); + } + + /** + * Move forward to next Item while iterating this Spine. + * @link http://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + */ + public function next(): void + { + next($this->items); + } + + /** + * Return the index of the current Item while iterating this Spine. + * + * @link http://php.net/manual/en/iterator.key.php + * @return int|null on success, or null on failure. + */ + public function key(): ?int + { + return key($this->items); + } + + /** + * Checks if current Iterator position is valid. + * + * @link http://php.net/manual/en/iterator.valid.php + * @return boolean true on success or false on failure. + */ + public function valid(): bool + { + return (bool) current($this->items); + } + + /** + * Rewind the Iterator to the first element. + * + * @link http://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + */ + public function rewind(): void + { + reset($this->items); + } + + /** + * Get the first Item of this Spine. + * + * @return Item + */ + public function first() + { + return reset($this->items); + } + + /** + * Get the last Item of this Spine. + * + * @return Item + */ + public function last() + { + return end($this->items); + } + + /** + * Count items of this Spine. + * + * @link https://php.net/manual/en/countable.count.php + * @return int The number of Items contained in this Spine. + */ + public function count(): int + { + return count($this->items); + } + + /** + * Whether a offset exists + * @link https://php.net/manual/en/arrayaccess.offsetexists.php + * @param int $offset An offset to check for. + * @return boolean true on success or false on failure. + */ + public function offsetExists($offset): bool + { + return isset($this->items[$offset]); + } + + /** + * Offset to retrieve + * @link https://php.net/manual/en/arrayaccess.offsetget.php + * @param int $offset The offset to retrieve. + * @return Item + */ + public function offsetGet($offset): Item + { + return $this->items[$offset]; + } + + /** + * Offset to set + * @link https://php.net/manual/en/arrayaccess.offsetset.php + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @throws BadMethodCallException + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException("Only reading array access is supported!"); + } + + /** + * Offset to unset + * @link https://php.net/manual/en/arrayaccess.offsetunset.php + * @param mixed $offset The offset to unset. + * @throws BadMethodCallException + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException("Only reading array access is supported!"); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Contents/Toc.php b/vendor/mikespub/php-epub-meta/src/Contents/Toc.php new file mode 100644 index 000000000..7992c4a8e --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Contents/Toc.php @@ -0,0 +1,63 @@ +<?php + +namespace SebLucas\EPubMeta\Contents; + +/** + * EPUB TOC structure for EPUB 2 + * + * @author Simon Schrape <simon@epubli.com> + */ +class Toc +{ + /** @var string */ + protected $docTitle; + /** @var string */ + protected $docAuthor; + /** @var NavPointList */ + protected $navMap; + + /** + * Summary of __construct + * @param string $title + * @param string $author + */ + public function __construct($title, $author) + { + $this->docTitle = $title; + $this->docAuthor = $author; + $this->navMap = new NavPointList(); + } + + /** + * @return string + */ + public function getDocTitle() + { + return $this->docTitle; + } + + /** + * @return string + */ + public function getDocAuthor() + { + return $this->docAuthor; + } + + /** + * @return NavPointList + */ + public function getNavMap() + { + return $this->navMap; + } + + /** + * @param string $file + * @return array|NavPoint[] + */ + public function findNavPointsForFile($file) + { + return $this->getNavMap()->findNavPointsForFile($file); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Data/Item.php b/vendor/mikespub/php-epub-meta/src/Data/Item.php new file mode 100644 index 000000000..271462920 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Data/Item.php @@ -0,0 +1,193 @@ +<?php + +namespace SebLucas\EPubMeta\Data; + +use SebLucas\EPubMeta\Tools\HtmlTools; +use DOMDocument; +use DOMElement; +use DOMText; +use DOMXPath; +use Exception; + +/** + * An item of the EPUB manifest. + * + * @author Simon Schrape <simon@epubli.com> + */ +class Item +{ + public const XHTML = 'application/xhtml+xml'; + /** @var string */ + protected $id; + /** @var string The path to the corresponding file. */ + protected $href; + /** @var string */ + protected $mediaType; + /** @var callable|null A callable to get data from the referenced file. */ + protected $dataCallable; + /** @var string The data read from the referenced file. */ + protected $data; + /** @var int The size of the referenced file. */ + protected $size; + + /** + * @param string $id This Item’s identifier. + * @param string $href The path to the corresponding file. + * @param callable $dataCallable A callable to get data from the referenced file. + * @param int $size The size of the referenced file. + * @param string|null $mediaType The media type of the corresponding file. If omitted XHTML is assumed. + */ + public function __construct($id, $href, $dataCallable, $size, $mediaType = null) + { + $this->id = $id; + $this->href = $href; + $this->dataCallable = $dataCallable; + $this->size = $size; + $this->mediaType = $mediaType ?: static::XHTML; + } + + /** + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getHref() + { + return $this->href; + } + + /** + * @return string + */ + public function getMediaType() + { + return $this->mediaType; + } + + /** + * Extract (a part of) the contents from the referenced XML file. + * + * @param string|null $fragmentBegin ID of the element where to start reading the contents. + * @param string|null $fragmentEnd ID of the element where to stop reading the contents. + * @param bool $keepMarkup Whether to keep the XHTML markup rather than extracted plain text. + * @return string The contents of that fragment. + * @throws Exception + */ + public function getContents($fragmentBegin = null, $fragmentEnd = null, $keepMarkup = false) + { + $dom = new DOMDocument(); + $dom->loadXML(HtmlTools::convertEntitiesNamedToNumeric($this->getData())); + + // get the starting point + if ($fragmentBegin) { + $xp = new DOMXPath($dom); + $node = $xp->query("//*[@id='$fragmentBegin']")->item(0); + if (!$node) { + throw new Exception("Begin of fragment not found: No element with ID $fragmentBegin!"); + } + } else { + $node = $dom->getElementsByTagName('body')->item(0) ?: $dom->documentElement; + } + + $allowableTags = [ + 'br', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'span', + 'div', + 'i', + 'strong', + 'b', + 'table', + 'td', + 'th', + 'tr', + ]; + $contents = ''; + $endTags = []; + /** @var DOMElement|DOMText $node */ + // traverse DOM structure till end point is reached, accumulating the contents + while ($node && (!$fragmentEnd || !$node->hasAttributes() || $node->getAttribute('id') != $fragmentEnd)) { + if ($node instanceof DOMText) { + // when encountering a text node append its value to the contents + $contents .= $keepMarkup ? htmlspecialchars($node->nodeValue) : $node->nodeValue; + } elseif ($node instanceof DOMElement) { + $tag = $node->localName; + if ($keepMarkup && in_array($tag, $allowableTags)) { + $contents .= "<$tag>"; + $endTags[] = "</$tag>"; + } elseif (HtmlTools::isBlockLevelElement($tag)) { + // add whitespace between contents of adjacent blocks + $endTags[] = PHP_EOL; + } else { + $endTags[] = ''; + } + + if ($node->hasChildNodes()) { + // step into + $node = $node->firstChild; + continue; + } + } + + // leave node + while ($node) { + if ($node instanceof DOMElement) { + $contents .= array_pop($endTags); + } + + if ($node->nextSibling) { + // step right + $node = $node->nextSibling; + break; + } elseif ($node = $node->parentNode) { + // step out + continue; + } elseif ($fragmentEnd) { + // reached end of DOM without finding fragment end + throw new Exception("End of fragment not found: No element with ID $fragmentEnd!"); + } + } + } + while ($endTags) { + $contents .= array_pop($endTags); + } + + return $contents; + } + + /** + * Get the file data. + * + * @return string The binary data of the corresponding file. + */ + public function getData() + { + if ($this->dataCallable) { + $this->data = call_user_func($this->dataCallable); + $this->dataCallable = null; + } + + return $this->data; + } + + /** + * Get the size of the corresponding file. + * + * @return int + */ + public function getSize() + { + return $this->size ?: strlen($this->getData()); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Data/Manifest.php b/vendor/mikespub/php-epub-meta/src/Data/Manifest.php new file mode 100644 index 000000000..428b2cc02 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Data/Manifest.php @@ -0,0 +1,174 @@ +<?php + +namespace SebLucas\EPubMeta\Data; + +use ArrayAccess; +use Countable; +use Exception; +use BadMethodCallException; +use Iterator; + +/** + * EPUB manifest structure + * + * @author Simon Schrape <simon@epubli.com> + * @implements \Iterator<string, Item> + * @implements \ArrayAccess<string, Item> + */ +class Manifest implements Iterator, Countable, ArrayAccess +{ + /** @var array|Item[] The map of all Items in this Manifest indexed by their IDs. */ + protected $items = []; + + /** + * Create and add an Item with the given properties. + * + * @param string $id The identifier of the new item. + * @param string $href The relative path of the referenced file in the EPUB. + * @param callable $callable A callable to get data from the referenced file in the EPUB. + * @param int $size The size of the referenced file in the EPUB. + * @param string|null $mediaType + * @return Item The newly created Item. + * @throws Exception If $id is already taken. + */ + public function createItem($id, $href, $callable, $size, $mediaType = null) + { + if (isset($this->items[$id])) { + throw new Exception("Item with ID $id already exists!"); + } + $item = new Item($id, $href, $callable, $size, $mediaType); + $this->items[$id] = $item; + + return $item; + } + + /** + * Return the current Item while iterating this Manifest. + * + * @link http://php.net/manual/en/iterator.current.php + * @return Item + */ + public function current(): Item + { + return current($this->items); + } + + /** + * Move forward to next Item while iterating this Manifest. + * @link http://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + */ + public function next(): void + { + next($this->items); + } + + /** + * Return the ID of the current Item while iterating this Manifest. + * + * @link http://php.net/manual/en/iterator.key.php + * @return string|null on success, or null on failure. + */ + public function key(): ?string + { + return key($this->items); + } + + /** + * Checks if current Iterator position is valid. + * + * @link http://php.net/manual/en/iterator.valid.php + * @return boolean true on success or false on failure. + */ + public function valid(): bool + { + return (bool) current($this->items); + } + + /** + * Rewind the Iterator to the first element. + * + * @link http://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + */ + public function rewind(): void + { + reset($this->items); + } + + /** + * Get the first Item of this Manifest. + * + * @return Item + */ + public function first() + { + return reset($this->items); + } + + /** + * Get the last Item of this Manifest. + * + * @return Item + */ + public function last() + { + return end($this->items); + } + + /** + * Count items of this Manifest. + * + * @link https://php.net/manual/en/countable.count.php + * @return int The number of Items contained in this Manifest. + */ + public function count(): int + { + return count($this->items); + } + + /** + * Whether a offset exists + * @link https://php.net/manual/en/arrayaccess.offsetexists.php + * @param string $offset An offset to check for. + * @return boolean true on success or false on failure. + */ + public function offsetExists($offset): bool + { + return isset($this->items[$offset]); + } + + /** + * Offset to retrieve + * @link https://php.net/manual/en/arrayaccess.offsetget.php + * @param string $offset The offset to retrieve. + * @return Item + */ + public function offsetGet($offset): Item + { + return $this->items[$offset]; + } + + /** + * Offset to set + * @link https://php.net/manual/en/arrayaccess.offsetset.php + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * @throws BadMethodCallException + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException("Only reading array access is supported!"); + } + + /** + * Offset to unset + * @link https://php.net/manual/en/arrayaccess.offsetunset.php + * @param mixed $offset The offset to unset. + * @throws BadMethodCallException + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException("Only reading array access is supported!"); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Dom/Element.php b/vendor/mikespub/php-epub-meta/src/Dom/Element.php new file mode 100644 index 000000000..2a7058eaa --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Dom/Element.php @@ -0,0 +1,196 @@ +<?php +/** + * PHP EPub Meta library + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Sébastien Lucas <sebastien@slucas.fr> + */ + +namespace SebLucas\EPubMeta\Dom; + +use DOMElement; + +/** + * EPUB-specific subclass of DOMElement + * + * Source: https://github.com/splitbrain/php-epub-meta + * @author Andreas Gohr <andi@splitbrain.org> © 2012 + * @author Simon Schrape <simon@epubli.com> © 2015–2018 + * @author Sébastien Lucas <sebastien@slucas.fr> + * + * @property string $nodeValueUnescaped + */ +class Element extends DOMElement +{ + /** @var array<string, string> */ + public static $namespaces = [ + 'n' => 'urn:oasis:names:tc:opendocument:xmlns:container', + 'opf' => 'http://www.idpf.org/2007/opf', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'ncx' => 'http://www.daisy.org/z3986/2005/ncx/', + ]; + + /** + * Summary of __construct + * @param string $name + * @param string $value + * @param string $namespaceUri + */ + public function __construct($name, $value = '', $namespaceUri = '') + { + [$prefix, $name] = $this->splitQualifiedName($name); + $value = htmlspecialchars($value); + if (!$namespaceUri && $prefix) { + //$namespaceUri = XmlNamespace::getUri($prefix); + $namespaceUri = static::$namespaces[$prefix]; + } + parent::__construct($name, $value, $namespaceUri); + } + + /** + * Summary of __get + * @param string $name + * @return string|null + */ + public function __get($name) + { + switch ($name) { + case 'nodeValueUnescaped': + return htmlspecialchars_decode($this->nodeValue); + } + + return null; + } + + /** + * Summary of __set + * @param string $name + * @param mixed $value + * @return void + */ + public function __set($name, $value) + { + switch ($name) { + case 'nodeValueUnescaped': + $this->nodeValue = htmlspecialchars($value); + } + } + + /** + * Create and append a new child + * + * Works with our epub namespaces and omits default namespaces + * @param string $name + * @param string $value + * @return Element|bool + */ + public function newChild($name, $value = '') + { + [$localName, $namespaceUri] = $this->getNameContext($name); + + // this doesn't call the constructor: $node = $this->ownerDocument->createElement($name,$value); + $node = new Element($namespaceUri ? $name : $localName, $value, $namespaceUri); + + /** @var Element $node */ + $node = $this->appendChild($node); + return $node; + } + + /** + * Simple EPUB namespace aware attribute getter + * @param string $name + * @return string + */ + public function getAttrib($name) + { + [$localName, $namespaceUri] = $this->getNameContext($name); + + // return value if none was given + if ($namespaceUri) { + return $this->getAttributeNS($namespaceUri, $localName); + } else { + return $this->getAttribute($localName); + } + } + + /** + * Simple EPUB namespace aware attribute setter + * @param string $name + * @param mixed $value + * @return void + */ + public function setAttrib($name, $value) + { + [$localName, $namespaceUri] = $this->getNameContext($name); + + if ($namespaceUri) { + $this->setAttributeNS($namespaceUri, $localName, $value); + } else { + $this->setAttribute($localName, $value); + } + } + + /** + * Simple EPUB namespace aware attribute remover + * @param string $name + * @return void + */ + public function removeAttrib($name) + { + [$localName, $namespaceUri] = $this->getNameContext($name); + + if ($namespaceUri) { + $this->removeAttributeNS($namespaceUri, $localName); + } else { + $this->removeAttribute($localName); + } + } + + /** + * Remove this node from the DOM + * @return void + */ + public function delete() + { + $this->parentNode->removeChild($this); + } + + /** + * Split given name in namespace prefix and local part + * + * @param string $name + * @return array<string> (prefix, name) + */ + protected function splitQualifiedName($name) + { + $list = explode(':', $name, 2); + if (count($list) < 2) { + array_unshift($list, ''); + } + + return $list; + } + + /** + * @param string $name + * @return array<string> + */ + protected function getNameContext($name) + { + [$prefix, $localName] = $this->splitQualifiedName($name); + + $namespaceUri = ''; + if ($prefix) { + //$namespaceUri = XmlNamespace::getUri($prefix); + $namespaceUri = static::$namespaces[$prefix]; + if ( + !$this->namespaceURI && $this->isDefaultNamespace($namespaceUri) + || $this->namespaceURI == $namespaceUri + ) { + $namespaceUri = ''; + } + } + + return [$localName, $namespaceUri]; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Dom/XPath.php b/vendor/mikespub/php-epub-meta/src/Dom/XPath.php new file mode 100644 index 000000000..4d208c1cd --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Dom/XPath.php @@ -0,0 +1,32 @@ +<?php +/** + * PHP EPub Meta library + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Sébastien Lucas <sebastien@slucas.fr> + */ + +namespace SebLucas\EPubMeta\Dom; + +use DOMDocument; +use DOMXPath; + +/** + * EPUB-specific subclass of DOMXPath + * + * Source: https://github.com/splitbrain/php-epub-meta + * @author Andreas Gohr <andi@splitbrain.org> © 2012 + * @author Simon Schrape <simon@epubli.com> © 2015–2018 + * @author Sébastien Lucas <sebastien@slucas.fr> + */ +class XPath extends DOMXPath +{ + public function __construct(DOMDocument $doc) + { + parent::__construct($doc); + + foreach (Element::$namespaces as $prefix => $namespaceUri) { + $this->registerNamespace($prefix, $namespaceUri); + } + } +} diff --git a/vendor/mikespub/php-epub-meta/src/EPub.php b/vendor/mikespub/php-epub-meta/src/EPub.php new file mode 100644 index 000000000..171fd7641 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/EPub.php @@ -0,0 +1,2126 @@ +<?php +/** + * PHP EPub Meta library + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Sébastien Lucas <sebastien@slucas.fr> + * @author Simon Schrape <simon@epubli.com> © 2015 + */ + +namespace SebLucas\EPubMeta; + +use SebLucas\EPubMeta\Dom\Element as EpubDomElement; +use SebLucas\EPubMeta\Dom\XPath as EpubDomXPath; +use SebLucas\EPubMeta\Data\Manifest; +use SebLucas\EPubMeta\Contents\Nav; +use SebLucas\EPubMeta\Contents\Spine; +use SebLucas\EPubMeta\Contents\NavPoint as TocNavPoint; +use SebLucas\EPubMeta\Contents\NavPointList as TocNavPointList; +use SebLucas\EPubMeta\Contents\Toc; +use SebLucas\EPubMeta\Tools\ZipEdit; +use SebLucas\EPubMeta\Tools\ZipFile; +use DOMDocument; +use DOMElement; +use DOMNodeList; +use Exception; +use InvalidArgumentException; +use JsonException; +use ZipArchive; + +class EPub +{ + /** Identifier for cover image inserted by this lib. */ + public const COVER_ID = 'php-epub-meta-cover'; + /** Identifier for title page inserted by this lib. */ + public const TITLE_PAGE_ID = 'php-epub-meta-titlepage'; + public const METADATA_FILE = 'META-INF/container.xml'; + public const MIME_TYPE = 'application/epub+zip'; + public const BOOKMARK_FILE = 'META-INF/calibre_bookmarks.txt'; + public const EPUB_FILE_TYPE_MAGIC = "encoding=json+base64:\n"; + /** @var array<int, array<string>> */ + public static $encodeNameReplace = [ + ['/', '-'], + ['~SLASH~', '~DASH~'], + ]; + /** @var array<int, array<string>> */ + public static $decodeNameReplace = [ + ['~SLASH~', '~DASH~'], + ['/', '-'], + ]; + + /** @var DOMDocument */ + public $xml; //FIXME: change to protected, later + /** @var DOMDocument|null */ + public $toc; + /** @var DOMDocument|null */ + public $nav; + /** @var EpubDomXPath */ + protected $xpath; + /** @var EpubDomXPath */ + protected $toc_xpath; + /** @var EpubDomXPath */ + protected $nav_xpath; + protected string $file; + protected string $meta; + /** @var ZipEdit|ZipFile */ + protected $zip; + protected string $zipClass; + protected string $coverpath = ''; + /** @var mixed */ + protected $namespaces; + protected string $imagetoadd = ''; + /** @var array<mixed> A map of ZIP items mapping filenames to file sizes */ + protected $zipSizeMap; + /** @var Manifest|null The manifest (catalog of files) of this EPUB */ + protected $manifest; + /** @var Spine|null The spine structure of this EPUB */ + protected $spine; + /** @var Toc|Nav|null The TOC structure of this EPUB */ + protected $tocnav; + protected int $epubVersion = 0; + + /** + * Constructor + * + * @param string $file path to epub file to work on + * @param string $zipClass class to handle zip - ZipFile is read-only + * @throws Exception if metadata could not be loaded + */ + public function __construct($file, $zipClass = ZipFile::class) + { + if (!is_file($file)) { + throw new Exception("Epub file does not exist!"); + } + if (filesize($file) <= 0) { + throw new Exception("Epub file is empty!"); + } + // open file + $this->file = $file; + $this->openZipFile($zipClass); + + // read container data + $this->loadMetadata(); + } + + /** + * Summary of openZipFile + * @param string $zipClass + * @throws \Exception + * @return void + */ + public function openZipFile($zipClass) + { + $this->zip = new $zipClass(); + if (!$this->zip->Open($this->file)) { + throw new Exception('Failed to read epub file'); + } + $this->zipClass = $zipClass; + } + + /** + * Summary of loadMetadata + * @throws \Exception + * @return void + */ + public function loadMetadata() + { + if (!$this->zip->FileExists(static::METADATA_FILE)) { + throw new Exception('Unable to find ' . static::METADATA_FILE); + } + + $data = $this->zip->FileRead(static::METADATA_FILE); + if ($data == false) { + throw new Exception('Failed to access epub container data'); + } + $xml = new DOMDocument(); + $xml->registerNodeClass(DOMElement::class, EpubDomElement::class); + $xml->loadXML($data); + $xpath = new EpubDomXPath($xml); + $nodes = $xpath->query('//n:rootfiles/n:rootfile[@media-type="application/oebps-package+xml"]'); + $this->meta = static::getAttr($nodes, 'full-path'); + + // load metadata + if (!$this->zip->FileExists($this->meta)) { + throw new Exception('Unable to find ' . $this->meta); + } + + $data = $this->zip->FileRead($this->meta); + if (!$data) { + throw new Exception('Failed to access epub metadata'); + } + $this->loadXmlData($data); + + $this->zipSizeMap = $this->loadSizeMap($this->file); + } + + /** + * Summary of loadXmlData + * @param string $data + * @return void + */ + public function loadXmlData($data) + { + $this->xml = new DOMDocument(); + $this->xml->registerNodeClass(DOMElement::class, EpubDomElement::class); + $this->xml->loadXML($data); + $this->xml->formatOutput = true; + $this->xpath = new EpubDomXPath($this->xml); + } + + /** + * Summary of initSpineComponent + * @throws \Exception + * @return void + */ + public function initSpineComponent() + { + $nodes = $this->xpath->query('//opf:spine'); + $tocid = static::getAttr($nodes, 'toc'); + if (empty($tocid)) { + $nodes = $this->xpath->query('//opf:manifest/opf:item[@properties="nav"]'); + $navhref = static::getAttr($nodes, 'href'); + $navpath = $this->getFullPath($navhref); + // read epub nav doc + if (!$this->zip->FileExists($navpath)) { + throw new Exception('Unable to find ' . $navpath); + } + $data = $this->zip->FileRead($navpath); + $this->loadNavData($data); + return; + } + $nodes = $this->xpath->query('//opf:manifest/opf:item[@id="' . $tocid . '"]'); + $tochref = static::getAttr($nodes, 'href'); + $tocpath = $this->getFullPath($tochref); + // read epub toc + if (!$this->zip->FileExists($tocpath)) { + throw new Exception('Unable to find ' . $tocpath); + } + + $data = $this->zip->FileRead($tocpath); + $this->loadTocData($data); + } + + /** + * Summary of loadNavData + * @param string $data + * @return void + */ + public function loadNavData($data) + { + $this->nav = new DOMDocument(); + $this->nav->registerNodeClass(DOMElement::class, EpubDomElement::class); + $this->nav->loadXML($data); + $this->nav_xpath = new EpubDomXPath($this->nav); + $rootNamespace = $this->nav->lookupNamespaceUri($this->nav->namespaceURI); + $this->nav_xpath->registerNamespace('x', $rootNamespace); + } + + /** + * Summary of loadTocData + * @param string $data + * @return void + */ + public function loadTocData($data) + { + $this->toc = new DOMDocument(); + $this->toc->registerNodeClass(DOMElement::class, EpubDomElement::class); + $this->toc->loadXML($data); + $this->toc_xpath = new EpubDomXPath($this->toc); + $rootNamespace = $this->toc->lookupNamespaceUri($this->toc->namespaceURI); + $this->toc_xpath->registerNamespace('x', $rootNamespace); + } + + /** + * Get the ePub version + * + * @return int The number of the ePub version (2 or 3 for now) or 0 if not found + */ + public function getEpubVersion() + { + if ($this->epubVersion) { + return $this->epubVersion; + } + + $this->epubVersion = 0; + $nodes = $this->xpath->query('//opf:package[@unique-identifier="BookId"]'); + if ($nodes->length) { + $this->epubVersion = (int) static::getAttr($nodes, 'version'); + } else { + $nodes = $this->xpath->query('//opf:package'); + if ($nodes->length) { + $this->epubVersion = (int) static::getAttr($nodes, 'version'); + } + } + + return $this->epubVersion; + } + + /** + * file name getter + * @return string + */ + public function file() + { + return $this->file; + } + + /** + * meta file getter + * @return string + */ + public function meta() + { + return $this->meta; + } + + /** + * Close the epub file + * @return void + */ + public function close() + { + $this->zip->FileCancelModif($this->meta); + // TODO: Add cancelation of cover image + $this->zip->Close(); + } + + /** + * Remove iTunes files + * @return void + */ + public function cleanITunesCrap() + { + if ($this->zip->FileExists('iTunesMetadata.plist')) { + $this->zip->FileDelete('iTunesMetadata.plist'); + } + if ($this->zip->FileExists('iTunesArtwork')) { + $this->zip->FileDelete('iTunesArtwork'); + } + } + + /** + * Writes back all meta data changes + * @return void + */ + public function save() + { + $this->download(); + $this->zip->close(); + } + + /** + * Get the updated epub + * @param mixed $file + * @param bool $sendHeaders + * @return void + */ + public function download($file = false, $sendHeaders = true) + { + $this->zip->FileReplace($this->meta, $this->xml->saveXML()); + // add the cover image + if ($this->imagetoadd) { + $this->zip->FileAddPath($this->coverpath, $this->imagetoadd); + $this->imagetoadd = ''; + } + if ($file) { + $render = $this->zipClass::DOWNLOAD; + $this->zip->Flush($render, $file, static::MIME_TYPE, $sendHeaders); + } elseif ($this->zipClass == ZipEdit::class) { + $this->zip->SaveBeforeClose(); + } + } + + /** + * Get the components list as an array + * @return array<mixed> + */ + public function components() + { + $spine = []; + $nodes = $this->xpath->query('//opf:spine/opf:itemref'); + foreach ($nodes as $node) { + /** @var EpubDomElement $node */ + $idref = $node->getAttribute('idref'); + /** @var EpubDomElement $item */ + $item = $this->xpath->query('//opf:manifest/opf:item[@id="' . $idref . '"]')->item(0); + $spine[] = $this->encodeComponentName($item->getAttribute('href')); + } + return $spine; + } + + /** + * Get the component content + * @param mixed $comp + * @return mixed + */ + public function component($comp) + { + $path = $this->decodeComponentName($comp); + $path = $this->getFullPath($path); + if (!$this->zip->FileExists($path)) { + $status = $this->zip->FileGetState($path); + throw new Exception('Unable to find ' . $path . ' <' . $comp . '> = ' . $status); + } + + $data = $this->zip->FileRead($path); + return $data; + } + + /** + * Summary of getComponentName + * @param mixed $comp + * @param mixed $elementPath + * @return bool|string + */ + public function getComponentName($comp, $elementPath) + { + $path = $this->decodeComponentName($comp); + $path = $this->getFullPath($path, $elementPath); + if (!$this->zip->FileExists($path)) { + error_log('Unable to find ' . $path); + return false; + } + $ref = dirname('/' . $this->meta); + $ref = ltrim($ref, '\\'); + $ref = ltrim($ref, '/'); + if (strlen($ref) > 0) { + $path = str_replace($ref . '/', '', $path); + } + return $this->encodeComponentName($path); + } + + /** + * Encode the component name (to replace / and -) + * @param mixed $src + * @return string + */ + protected static function encodeComponentName($src) + { + return str_replace( + static::$encodeNameReplace[0], + static::$encodeNameReplace[1], + $src + ); + } + + /** + * Decode the component name (to replace / and -) + * @param mixed $src + * @return string + */ + protected static function decodeComponentName($src) + { + return str_replace( + static::$decodeNameReplace[0], + static::$decodeNameReplace[1], + $src + ); + } + + + /** + * Get the component content type + * @param mixed $comp + * @return string + */ + public function componentContentType($comp) + { + $comp = $this->decodeComponentName($comp); + $nodes = $this->xpath->query('//opf:manifest/opf:item[@href="' . $comp . '"]'); + if ($nodes->length) { + return static::getAttr($nodes, 'media-type'); + } + + // I had at least one book containing %20 instead of spaces in the opf file + $comp = str_replace(' ', '%20', $comp); + $nodes = $this->xpath->query('//opf:manifest/opf:item[@href="' . $comp . '"]'); + if ($nodes->length) { + return static::getAttr($nodes, 'media-type'); + } + return 'application/octet-stream'; + } + + /** + * Summary of getComponentSize + * @param mixed $comp + * @return bool|int + */ + public function getComponentSize($comp) + { + $path = $this->decodeComponentName($comp); + $path = $this->getFullPath($path); + if (!$this->zip->FileExists($path)) { + error_log('Unable to find ' . $path); + return false; + } + + $sizeMap = $this->loadSizeMap(); + return $sizeMap[$path]; + } + + /** + * EPUB 2 navigation control file (NCX format) + * See https://idpf.org/epub/20/spec/OPF_2.0_latest.htm#Section2.4.1 + * @param mixed $node + * @return array<string, string> + */ + protected function getNavPointDetail($node) + { + $title = $this->toc_xpath->query('x:navLabel/x:text', $node)->item(0)->nodeValue; + $nodes = $this->toc_xpath->query('x:content', $node); + $src = static::getAttr($nodes, 'src'); + $src = $this->encodeComponentName($src); + $item = ['title' => preg_replace('~[\r\n]+~', '', $title), 'src' => $src]; + $insidenodes = $this->toc_xpath->query('x:navPoint', $node); + if (count($insidenodes) < 1) { + return $item; + } + $item['children'] = []; + foreach ($insidenodes as $insidenode) { + $item['children'][] = $this->getNavPointDetail($insidenode); + } + return $item; + } + + /** + * EPUB 3 navigation document (toc nav element) + * See https://www.w3.org/TR/epub-33/#sec-nav-toc + * @param mixed $node + * @return array<string, string> + */ + protected function getNavTocListItem($node) + { + $nodes = $this->nav_xpath->query('x:a', $node); + $title = $nodes->item(0)->nodeValue; + $src = static::getAttr($nodes, 'href'); + $src = $this->encodeComponentName($src); + $item = ['title' => preg_replace('~[\r\n]+~', '', $title), 'src' => $src]; + $insidenodes = $this->nav_xpath->query('x:ol/x:li', $node); + if (count($insidenodes) < 1) { + return $item; + } + $item['children'] = []; + foreach ($insidenodes as $insidenode) { + $item['children'][] = $this->getNavTocListItem($insidenode); + } + return $item; + } + + /** + * Get the Epub content (TOC) as an array + * + * For each chapter there is a "title" and a "src", and optional "children" + * See https://github.com/joseph/Monocle/wiki/Book-data-object for details + * @return mixed + */ + public function contents() + { + $contents = []; + if (!empty($this->nav)) { + $toc = $this->nav_xpath->query('//x:nav[@epub:type="toc"]')->item(0); + $nodes = $this->nav_xpath->query('x:ol/x:li', $toc); + foreach ($nodes as $node) { + $contents[] = $this->getNavTocListItem($node); + } + return $contents; + } + $nodes = $this->toc_xpath->query('//x:ncx/x:navMap/x:navPoint'); + foreach ($nodes as $node) { + $contents[] = $this->getNavPointDetail($node); + } + return $contents; + } + + /** + * Set the book author(s) + * + * Authors should be given with a "file-as" and a real name. The file as + * is used for sorting in e-readers. + * + * Example: + * + * array( + * 'Pratchett, Terry' => 'Terry Pratchett', + * 'Simpson, Jacqueline' => 'Jacqueline Simpson', + * ) + * + * @param mixed $authors + * @return void + */ + public function setAuthors($authors) + { + // Author where given as a comma separated list + if (is_string($authors)) { + if ($authors == '') { + $authors = []; + } else { + $authors = explode(',', $authors); + $authors = array_map('trim', $authors); + } + } + + // delete existing nodes + $nodes = $this->xpath->query('//opf:metadata/dc:creator[@opf:role="aut"]'); + static::deleteNodes($nodes); + + // add new nodes + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:metadata')->item(0); + foreach ($authors as $as => $name) { + if (is_int($as)) { + $as = $name; //numeric array given + } + $node = $parent->newChild('dc:creator', $name); + $node->setAttrib('opf:role', 'aut'); + $node->setAttrib('opf:file-as', $as); + } + + $this->reparse(); + } + + /** + * Get the book author(s) + * @return array<string> + */ + public function getAuthors() + { + $rolefix = false; + $authors = []; + $nodes = $this->xpath->query('//opf:metadata/dc:creator[@opf:role="aut"]'); + if ($nodes->length == 0) { + // no nodes where found, let's try again without role + $nodes = $this->xpath->query('//opf:metadata/dc:creator'); + $rolefix = true; + } + foreach ($nodes as $node) { + /** @var EpubDomElement $node */ + $name = $node->nodeValue; + $as = $node->getAttrib('opf:file-as'); + if (!$as) { + $as = $name; + $node->setAttrib('opf:file-as', $as); + } + if ($rolefix) { + $node->setAttrib('opf:role', 'aut'); + } + $authors[$as] = $name; + } + return $authors; + } + + /** + * Set or get the Google Books ID + * + * @param string|bool $google + * @return mixed + */ + public function Google($google = false) + { + return $this->getset('dc:identifier', $google, 'opf:scheme', 'GOOGLE'); + } + + /** + * Set or get the Amazon ID of the book + * + * @param string|bool $amazon + * @return mixed + */ + public function Amazon($amazon = false) + { + return $this->getset('dc:identifier', $amazon, 'opf:scheme', 'AMAZON'); + } + + /** + * Set the Series of the book + * + * @param string $serie + * @return void + */ + public function setSeries($serie) + { + $this->setMetaDestination('opf:meta', 'name', 'calibre:series', 'content', $serie); + } + + /** + * Get the Series of the book + * + * @return mixed + */ + public function getSeries() + { + return $this->getMetaDestination('opf:meta', 'name', 'calibre:series', 'content'); + } + + /** + * Set the Series Index of the book + * + * @param string $seriesIndex + * @return void + */ + public function setSeriesIndex($seriesIndex) + { + $this->setMetaDestination('opf:meta', 'name', 'calibre:series_index', 'content', $seriesIndex); + } + + /** + * Get the Series Index of the book + * + * @return mixed + */ + public function getSeriesIndex() + { + return $this->getMetaDestination('opf:meta', 'name', 'calibre:series_index', 'content'); + } + + /** + * Set the book's subjects (aka. tags) + * + * Subject should be given as array, but a comma separated string will also + * be accepted. + * + * @param array<string>|string $subjects + * @return void + */ + public function setSubjects($subjects) + { + if (is_string($subjects)) { + if ($subjects === '') { + $subjects = []; + } else { + $subjects = explode(',', $subjects); + $subjects = array_map('trim', $subjects); + } + } + + // delete previous + $nodes = $this->xpath->query('//opf:metadata/dc:subject'); + static::deleteNodes($nodes); + // add new ones + $parent = $this->xpath->query('//opf:metadata')->item(0); + foreach ($subjects as $subj) { + $node = $this->xml->createElement('dc:subject', htmlspecialchars($subj)); + $node = $parent->appendChild($node); + } + + $this->reparse(); + } + + /** + * Get the book's subjects (aka. tags) + * @return array<mixed> + */ + public function getSubjects() + { + $subjects = []; + $nodes = $this->xpath->query('//opf:metadata/dc:subject'); + foreach ($nodes as $node) { + $subjects[] = $node->nodeValue; + } + return $subjects; + } + + /** + * Update the cover data + * + * When adding a new image this function return no or old data because the + * image contents are not in the epub file, yet. The image will be added when + * the save() method is called. + * + * @param string $path local filesystem path to a new cover image + * @param string $mime mime type of the given file + * @return void + */ + public function setCoverInfo($path, $mime) + { + // remove current pointer + $nodes = $this->xpath->query('//opf:metadata/opf:meta[@name="cover"]'); + static::deleteNodes($nodes); + // remove previous manifest entries if they where made by us + $nodes = $this->xpath->query('//opf:manifest/opf:item[@id="' . static::COVER_ID . '"]'); + static::deleteNodes($nodes); + + // add pointer + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:metadata')->item(0); + $node = $parent->newChild('opf:meta'); + $node->setAttrib('opf:name', 'cover'); + $node->setAttrib('opf:content', static::COVER_ID); + + // add manifest + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:manifest')->item(0); + $node = $parent->newChild('opf:item'); + $node->setAttrib('id', static::COVER_ID); + $node->setAttrib('opf:href', static::COVER_ID . '.img'); + $node->setAttrib('opf:media-type', $mime); + + // remember path for save action + $this->imagetoadd = $path; + $this->coverpath = $this->getFullPath(static::COVER_ID . '.img'); + + $this->reparse(); + } + + /** + * Read the cover data + * + * Returns an associative array with the following keys: + * + * mime - filetype (usually image/jpeg) + * data - the binary image data + * found - the internal path, or false if no image is set in epub + * + * When no image is set in the epub file, the binary data for a transparent + * GIF pixel is returned. + * + * @return array<mixed> + */ + public function getCoverInfo() + { + $item = $this->getCoverItem(); + if (!$item) { + return $this->no_cover(); + } + + $mime = $item->getAttrib('opf:media-type'); + $path = $item->getAttrib('opf:href'); + $path = dirname('/' . $this->meta) . '/' . $path; // image path is relative to meta file + $path = ltrim($path, '/'); + + $zip = new ZipArchive(); + if (!@$zip->open($this->file)) { + throw new Exception('Failed to read epub file'); + } + $data = $zip->getFromName($path); + + return [ + 'mime' => $mime, + 'data' => $data, + 'found' => $path, + ]; + } + + /** + * Summary of getCoverId + * @return string|null + */ + public function getCoverId() + { + $nodes = $this->xpath->query('//opf:metadata/opf:meta[@name="cover"]'); + if (!$nodes->length) { + return null; + } + + $coverid = (string) static::getAttr($nodes, 'opf:content'); + if (!$coverid) { + return null; + } + + return $coverid; + } + + /** + * Summary of getCoverItem + * @return EpubDomElement|null + */ + public function getCoverItem() + { + $coverid = $this->getCoverId(); + if (!$coverid) { + return null; + } + + $nodes = $this->xpath->query('//opf:manifest/opf:item[@id="' . $coverid . '"]'); + if (!$nodes->length) { + return null; + } + + /** @var EpubDomElement $node */ + $node = $nodes->item(0); + return $node; + } + + /** + * Get the internal path of the cover image file. + * + * @return string|null + */ + public function getCoverPath() + { + $item = $this->getCoverItem(); + if (!$item) { + return null; + } + + return $item->getAttrib('opf:href'); + } + + /** + * Summary of Combine + * @param mixed $a + * @param mixed $b + * @throws \InvalidArgumentException + * @return string + */ + public static function Combine($a, $b) + { + $isAbsolute = false; + if ($a[0] == '/') { + $isAbsolute = true; + } + + if ($b[0] == '/') { + throw new InvalidArgumentException('Second path part must not start with /'); + } + + $splittedA = preg_split('#/#', $a); + $splittedB = preg_split('#/#', $b); + + $pathParts = []; + $mergedPath = array_merge($splittedA, $splittedB); + + foreach ($mergedPath as $item) { + if ($item == null || $item == '' || $item == '.') { + continue; + } + + if ($item == '..') { + array_pop($pathParts); + continue; + } + + array_push($pathParts, $item); + } + + $path = implode('/', $pathParts); + if ($isAbsolute) { + return('/' . $path); + } else { + return($path); + } + } + + /** + * Summary of getFullPath + * @param mixed $file + * @param mixed $context + * @return string + */ + protected function getFullPath($file, $context = null) + { + $path = dirname('/' . $this->meta) . '/' . $file; + $path = ltrim($path, '\\'); + $path = ltrim($path, '/'); + if (!empty($context)) { + $path = $this->combine(dirname($path), $context); + } + //error_log ("FullPath : $path ($file / $context)"); + return $path; + } + + /** + * Summary of updateForKepub + * @return void + */ + public function updateForKepub() + { + $item = $this->getCoverItem(); + if (!is_null($item)) { + $item->setAttrib('opf:properties', 'cover-image'); + } + } + + /** + * Summary of setCoverFile + * @param string $path + * @param string $mime + * @return array<mixed>|void + */ + public function setCoverFile($path, $mime) + { + $hascover = true; + $item = $this->getCoverItem(); + if (is_null($item)) { + $hascover = false; + return; // TODO For now only update + } else { + $mime = $item->getAttrib('opf:media-type'); + $this->coverpath = $item->getAttrib('opf:href'); + $this->coverpath = dirname('/' . $this->meta) . '/' . $this->coverpath; // image path is relative to meta file + $this->coverpath = ltrim($this->coverpath, '\\'); + $this->coverpath = ltrim($this->coverpath, '/'); + } + + // set cover + + $item->setAttrib('opf:media-type', $mime); + + // remember path for save action + $this->imagetoadd = $path; + + $this->reparse(); + + // not very useful here, but data gets added in download() if needed + return [ + 'data' => null, + 'mime' => $mime, + 'found' => $this->coverpath, + ]; + } + + /** + * Summary of getAttr + * @param DOMNodeList<EpubDomElement> $nodes list of Element items + * @param string $att Attribute name + * @return string + */ + protected static function getAttr($nodes, $att) + { + $node = $nodes->item(0); + return $node->getAttrib($att); + } + + /** + * Summary of deleteNodes + * @param DOMNodeList<EpubDomElement> $nodes list of Element items + * @return void + */ + protected static function deleteNodes($nodes) + { + foreach ($nodes as $node) { + $node->delete(); + } + } + + /** + * A simple getter/setter for simple meta attributes + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to set/get + * @param string|bool $value New node value + * @param string|bool $att Attribute name + * @param string|bool|array<mixed> $aval Attribute value + * @param string|bool $datt Destination attribute + * @return string|void + */ + protected function getset($item, $value = false, $att = false, $aval = false, $datt = false) + { + // construct xpath + $xpath = '//opf:metadata/' . $item; + if ($att) { + if (is_array($aval)) { + $xpath .= '[@' . $att . '="'; + $xpath .= implode("\" or @$att=\"", $aval); + $xpath .= '"]'; + } else { + $xpath .= '[@' . $att . '="' . $aval . '"]'; + } + } + + // set value + if ($value !== false) { + $value = htmlspecialchars($value); + $nodes = $this->xpath->query($xpath); + if ($nodes->length == 1) { + /** @var EpubDomElement $node */ + $node = $nodes->item(0); + if ($value === '') { + // the user want's to empty this value -> delete the node + $node->delete(); + } else { + // replace value + if ($datt) { + $node->setAttrib($datt, $value); + } else { + $node->nodeValue = $value; + } + } + } else { + // if there are multiple matching nodes for some reason delete + // them. we'll replace them all with our own single one + static::deleteNodes($nodes); + // readd them + if ($value) { + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:metadata')->item(0); + + $node = $parent->newChild($item); + if ($att) { + $node->setAttrib($att, $aval); + } + if ($datt) { + $node->setAttrib($datt, $value); + } else { + $node->nodeValue = $value; + } + } + } + + $this->reparse(); + } + + // get value + $nodes = $this->xpath->query($xpath); + if ($nodes->length) { + /** @var EpubDomElement $node */ + $node = $nodes->item(0); + if ($datt) { + return $node->getAttrib($datt); + } else { + return $node->nodeValue; + } + } else { + return ''; + } + } + + /** + * Return a not found response for Cover() + * @return array<string, mixed> + */ + protected function no_cover() + { + return [ + 'data' => base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAIBTAA7'), + 'mime' => 'image/gif', + 'found' => false, + ]; + } + + /** + * Reparse the DOM tree + * + * I had to rely on this because otherwise xpath failed to find the newly + * added nodes + * @return void + */ + protected function reparse() + { + $this->xml->loadXML($this->xml->saveXML()); + $this->xpath = new EpubDomXPath($this->xml); + // reset structural members + $this->manifest = null; + $this->spine = null; + $this->tocnav = null; + } + + /** based on slightly more updated version at https://github.com/epubli/epub */ + + /** + * A simple setter for simple meta attributes + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to set + * @param string $value New node value + * @param bool|string $attribute Attribute name + * @param bool|string $attributeValue Attribute value + * @param bool $caseSensitive + * @return mixed + */ + protected function setMeta($item, $value, $attribute = false, $attributeValue = false, $caseSensitive = true) + { + /** + if ($attributeValue !== false && !$caseSensitive) { + $attval = is_array($attributeValue) ? $attributeValue : [ $attributeValue ]; + $vallist = []; + foreach ($attval as $val) { + $vallist[] = strtoupper($val); + $vallist[] = strtolower($val); + } + $attributeValue = $vallist; + } + */ + return $this->getset($item, $value, $attribute, $attributeValue); + } + + /** + * A simple getter for simple meta attributes + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to get + * @param bool|string $att Attribute name + * @param bool|string $aval Attribute value + * @param bool $caseSensitive + * @return string + */ + protected function getMeta($item, $att = false, $aval = false, $caseSensitive = true) + { + /** + if ($aval !== false && !$caseSensitive) { + $attval = is_array($aval) ? $aval : [ $aval ]; + $vallist = []; + foreach ($attval as $val) { + $vallist[] = strtoupper($val); + $vallist[] = strtolower($val); + } + $aval = $vallist; + } + */ + return $this->getset($item, false, $att, $aval); + } + + /** + * A simple setter for simple meta attributes - with destination attribute (for Serie) + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to set + * @param string $attribute Attribute name + * @param string $attributeValue Attribute value + * @param string $datt Destination attribute + * @param string $value New node value + * @return mixed + */ + protected function setMetaDestination($item, $attribute, $attributeValue, $datt, $value) + { + return $this->getset($item, $value, $attribute, $attributeValue, $datt); + } + + /** + * A simple getter for simple meta attributes - with destination attribute (for Serie) + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to get + * @param string $att Attribute name + * @param string $aval Attribute value + * @param string $datt Destination attribute + * @return string + */ + protected function getMetaDestination($item, $att, $aval, $datt) + { + return $this->getset($item, false, $att, $aval, $datt); + } + + /** + * Set the book title + * + * @param string $title + * @return mixed + */ + public function setTitle($title) + { + return $this->getset('dc:title', $title); + } + + /** + * Get the book title + * + * @return mixed + */ + public function getTitle() + { + return $this->getset('dc:title'); + } + + /** + * Set the book's language + * + * @param string $lang + * @return mixed + */ + public function setLanguage($lang) + { + return $this->getset('dc:language', $lang); + } + + /** + * Get the book's language + * + * @return mixed + */ + public function getLanguage() + { + return $this->getset('dc:language'); + } + + /** + * Set the book's publisher info + * + * @param string $publisher + * @return void + */ + public function setPublisher($publisher) + { + $this->setMeta('dc:publisher', $publisher); + } + + /** + * Get the book's publisher info + * + * @return string + */ + public function getPublisher() + { + return $this->getMeta('dc:publisher'); + } + + /** + * Set the book's copyright info + * + * @param string $rights + * @return void + */ + public function setCopyright($rights) + { + $this->setMeta('dc:rights', $rights); + } + + /** + * Get the book's copyright info + * + * @return string + */ + public function getCopyright() + { + return $this->getMeta('dc:rights'); + } + + /** + * Set the book's description + * + * @param string $description + * @return void + */ + public function setDescription($description) + { + $this->setMeta('dc:description', $description); + } + + /** + * Get the book's description + * + * @return string + */ + public function getDescription() + { + return $this->getMeta('dc:description'); + } + + /** + * Set a date for an event in the package file’s meta section. + * + * @param string $event + * @param string $date Date eg: 2012-05-19T12:54:25Z + * @return void + */ + public function setEventDate($event, $date) + { + $this->getset('dc:date', $date, 'opf:event', $event); + } + + /** + * Get a date for an event in the package file’s meta section. + * + * @param string $event + * @return mixed + */ + public function getEventDate($event) + { + $res = $this->getset('dc:date', false, 'opf:event', $event); + + return $res; + } + + /** + * Set the book's creation date + * + * @param string $date Date eg: 2012-05-19T12:54:25Z + * @return void + */ + public function setCreationDate($date) + { + $this->setEventDate('creation', $date); + } + + /** + * Get the book's creation date + * + * @return mixed + */ + public function getCreationDate() + { + $res = $this->getEventDate('creation'); + + return $res; + } + + /** + * Set the book's modification date + * + * @param string $date Date eg: 2012-05-19T12:54:25Z + * @return void + */ + public function setModificationDate($date) + { + $this->setEventDate('modification', $date); + } + + /** + * Get the book's modification date + * + * @return mixed + */ + public function getModificationDate() + { + $res = $this->getEventDate('modification'); + + return $res; + } + + /** + * Set an identifier in the package file’s meta section. + * + * @param string|array<string> $idScheme The identifier’s scheme. If an array is given + * all matching identifiers are replaced by one with the first value as scheme. + * @param string $value + * @param bool $caseSensitive + * @return void + */ + public function setIdentifier($idScheme, $value, $caseSensitive = false) + { + $this->setMeta('dc:identifier', $value, 'opf:scheme', $idScheme, $caseSensitive); + } + + /** + * Set an identifier from the package file’s meta section. + * + * @param string|array<string> $idScheme The identifier’s scheme. If an array is given + * the scheme can be any of its values. + * @param bool $caseSensitive - @todo changed to true here + * @return string The value of the first matching element. + */ + public function getIdentifier($idScheme, $caseSensitive = true) + { + return $this->getMeta('dc:identifier', 'opf:scheme', $idScheme, $caseSensitive); + } + + /** + * Set the book's unique identifier + * + * @param string $value + * @return void + */ + public function setUniqueIdentifier($value) + { + //$nodes = $this->xpath->query('/opf:package'); + $idRef = $this->xpath->document->documentElement->getAttribute('unique-identifier'); + $this->setMeta('dc:identifier', $value, 'id', $idRef); + } + + /** + * Get the book's unique identifier + * + * @param bool $normalize + * @return string + */ + public function getUniqueIdentifier($normalize = false) + { + //$nodes = $this->xpath->query('/opf:package'); + $idRef = $this->xpath->document->documentElement->getAttribute('unique-identifier'); + $idVal = $this->getMeta('dc:identifier', 'id', $idRef); + if ($normalize) { + $idVal = strtolower($idVal); + $idVal = str_replace('urn:uuid:', '', $idVal); + } + + return $idVal; + } + + /** + * Set the book's UUID - @todo pick one + case sensitive + * + * @param string $uuid + * @return void + */ + public function setUuid($uuid) + { + //$this->setIdentifier(['UUID', 'uuid', 'URN', 'urn'], $uuid); + $this->setIdentifier('URN', $uuid); + } + + /** + * Get the book's UUID - @todo pick one + case sensitive + * + * @return string + */ + public function getUuid() + { + //return $this->getIdentifier(['uuid', 'urn']); + return $this->getIdentifier(['UUID', 'URN']); + } + + /** + * Set the book's URI + * + * @param string $uri + * @return void + */ + public function setUri($uri) + { + $this->setIdentifier('URI', $uri); + } + + /** + * Get the book's URI + * + * @return string + */ + public function getUri() + { + return $this->getIdentifier('URI'); + } + + /** + * Set the book's ISBN + * + * @param string $isbn + * @return void + */ + public function setIsbn($isbn) + { + $this->setIdentifier('ISBN', $isbn); + } + + /** + * Get the book's ISBN + * + * @return string + */ + public function getIsbn() + { + return $this->getIdentifier('ISBN'); + } + + /** + * Set the Calibre UUID of the book + * + * @param string $uuid + * @return void + */ + public function setCalibre($uuid) + { + $this->setIdentifier('calibre', $uuid); + } + + /** + * Get the Calibre UUID of the book + * + * @return string + */ + public function getCalibre() + { + return $this->getIdentifier('calibre'); + } + + /** + * Remove the cover image + * + * If the actual image file was added by this library it will be removed. Otherwise only the + * reference to it is removed from the metadata, since the same image might be referenced + * by other parts of the EPUB file. + * @return void + */ + public function clearCover() + { + if (!$this->hasCover()) { + return; + } + + $manifest = $this->getManifest(); + + // remove any cover image file added by us + if (isset($manifest[static::COVER_ID])) { + $name = $this->getFullPath(static::COVER_ID . '.img'); + if (!$this->zip->FileDelete($name)) { + throw new Exception('Unable to remove ' . $name); + } + } + + // remove metadata cover pointer + $nodes = $this->xpath->query('//opf:metadata/opf:meta[@name="cover"]'); + static::deleteNodes($nodes); + + // remove previous manifest entries if they where made by us + $nodes = $this->xpath->query('//opf:manifest/opf:item[@id="' . static::COVER_ID . '"]'); + static::deleteNodes($nodes); + + $this->reparse(); + } + + /** + * Set the cover image + * + * @param string $path local filesystem path to a new cover image + * @param string $mime mime type of the given file + * @return void + */ + public function setCover($path, $mime) + { + if (!$path) { + throw new InvalidArgumentException('Parameter $path must not be empty!'); + } + + if (!is_readable($path)) { + throw new InvalidArgumentException("Cannot add $path as new cover image since that file is not readable!"); + } + + $this->clearCover(); + + // add metadata cover pointer + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:metadata')->item(0); + $node = $parent->newChild('opf:meta'); + $node->setAttrib('opf:name', 'cover'); + $node->setAttrib('opf:content', static::COVER_ID); + + // add manifest item + /** @var EpubDomElement $parent */ + $parent = $this->xpath->query('//opf:manifest')->item(0); + $node = $parent->newChild('opf:item'); + $node->setAttrib('id', static::COVER_ID); + $node->setAttrib('opf:href', static::COVER_ID . '.img'); + $node->setAttrib('opf:media-type', $mime); + + // add the cover image + $name = $this->getFullPath(static::COVER_ID . '.img'); + if (!$this->zip->FileAddPath($name, $path)) { + throw new Exception('Unable to add ' . $name); + } + + $this->reparse(); + } + + /** + * Get the cover image + * + * @return string|null The binary image data or null if no image exists. + */ + public function getCover() + { + $comp = $this->getCoverPath(); + + return $comp ? $this->component($comp) : null; + } + + /** + * Whether a cover image meta entry does exist. + * + * @return bool + */ + public function hasCover() + { + return !empty($this->getCoverId()); + } + + /** + * Add a title page with the cover image to the EPUB. + * + * @param string $templatePath The path to the template file. Defaults to an XHTML file contained in this library. + * @return void + */ + public function addCoverImageTitlePage($templatePath = __DIR__ . '/../templates/titlepage.xhtml') + { + $xhtmlFilename = static::TITLE_PAGE_ID . '.xhtml'; + + // add title page file to zip + $template = file_get_contents($templatePath); + $xhtml = strtr($template, ['{{ title }}' => $this->getTitle(), '{{ coverPath }}' => $this->getCoverPath()]); + $name = $this->getFullPath($xhtmlFilename); + if (!$this->zip->FileReplace($name, $xhtml)) { + throw new Exception('Unable to replace ' . $name); + } + + // prepend title page file to manifest + $parent = $this->xpath->query('//opf:manifest')->item(0); + $node = new EpubDomElement('opf:item'); + $parent->insertBefore($node, $parent->firstChild); + $node->setAttrib('id', static::TITLE_PAGE_ID); + $node->setAttrib('opf:href', $xhtmlFilename); + $node->setAttrib('opf:media-type', 'application/xhtml+xml'); + + // prepend title page spine item + $parent = $this->xpath->query('//opf:spine')->item(0); + $node = new EpubDomElement('opf:itemref'); + $parent->insertBefore($node, $parent->firstChild); + $node->setAttrib('idref', static::TITLE_PAGE_ID); + + // prepend title page guide reference + $parent = $this->xpath->query('//opf:guide')->item(0); + $node = new EpubDomElement('opf:reference'); + $parent->insertBefore($node, $parent->firstChild); + $node->setAttrib('opf:href', $xhtmlFilename); + $node->setAttrib('opf:type', 'cover'); + $node->setAttrib('opf:title', 'Title Page'); + } + + /** + * Remove the title page added by this library (determined by a certain manifest item ID). + * @return void + */ + public function removeTitlePage() + { + $xhtmlFilename = static::TITLE_PAGE_ID . '.xhtml'; + + // remove title page file from zip + $name = $this->getFullPath($xhtmlFilename); + if (!$this->zip->FileDelete($name)) { + throw new Exception('Unable to remove ' . $name); + } + + // remove title page file from manifest + $nodes = $this->xpath->query('//opf:manifest/opf:item[@id="' . static::TITLE_PAGE_ID . '"]'); + static::deleteNodes($nodes); + + // remove title page spine item + $nodes = $this->xpath->query('//opf:spine/opf:itemref[@idref="' . static::TITLE_PAGE_ID . '"]'); + static::deleteNodes($nodes); + + // remove title page guide reference + $nodes = $this->xpath->query('//opf:guide/opf:reference[@href="' . $xhtmlFilename . '"]'); + static::deleteNodes($nodes); + } + + /** + * Get the Calibre book annotations from opf:metadata (if saved) + * @param ?string $data + * @return array<mixed> + */ + public function getCalibreAnnotations($data = null) + { + if (!empty($data)) { + $this->loadXmlData($data); + } + $annotations = []; + $nodes = $this->xpath->query('//opf:metadata/opf:meta[@name="calibre:annotation"]'); + if ($nodes->length == 0) { + return $annotations; + } + foreach ($nodes as $node) { + /** @var EpubDomElement $node */ + $content = $node->getAttribute('content'); + try { + $annotations[] = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $annotations[] = $content; + } + } + return $annotations; + } + + /** + * Get the Calibre bookmarks from META-INF/calibre_bookmarks.txt (if saved) + * @param ?string $data + * @return array<mixed> + */ + public function getCalibreBookmarks($data = null) + { + if (empty($data)) { + if (!$this->zip->FileExists(static::BOOKMARK_FILE)) { + throw new Exception('Unable to find ' . static::BOOKMARK_FILE); + } + $data = $this->zip->FileRead(static::BOOKMARK_FILE); + if ($data == false) { + throw new Exception('Failed to access epub bookmark file'); + } + } + if (!str_starts_with($data, static::EPUB_FILE_TYPE_MAGIC)) { + throw new Exception('Invalid format for epub bookmark file'); + } + $content = substr($data, strlen(static::EPUB_FILE_TYPE_MAGIC)); + $content = base64_decode($content); + try { + $bookmarks = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $bookmarks = $content; + } + return $bookmarks; + } + + /** + * Get the manifest of this EPUB. + * + * @return Manifest + * @throws Exception + */ + public function getManifest() + { + if ($this->manifest) { + return $this->manifest; + } + + /** @var EpubDomElement|null $manifestNode */ + $manifestNode = $this->xpath->query('//opf:manifest')->item(0); + if (is_null($manifestNode)) { + throw new Exception('No manifest element found in EPUB!'); + } + + $this->manifest = new Manifest(); + /** @var EpubDomElement $item */ + foreach ($manifestNode->getElementsByTagName('item') as $item) { + $id = $item->getAttribute('id'); + $href = urldecode($item->getAttribute('href')); + $fullPath = $this->getFullPath($href); + // this won't work with clsTbsZip - $this->zip->getStream($fullPath); + //$handle = $this->zip->FileStream($fullPath); + $callable = function () use ($fullPath) { + // Automatic binding of $this + return $this->zip->FileRead($fullPath); + }; + $size = $this->zipSizeMap[$fullPath] ?? 0; + $mediaType = $item->getAttribute('media-type'); + $this->manifest->createItem($id, $href, $callable, $size, $mediaType); + } + + return $this->manifest; + } + + /** + * Get the spine structure of this EPUB. + * + * @return Spine + * @throws Exception + */ + public function getSpine() + { + if ($this->spine) { + return $this->spine; + } + + $nodes = $this->xpath->query('//opf:spine'); + if (!$nodes->length) { + throw new Exception('No spine element found in EPUB!'); + } + $tocId = static::getAttr($nodes, 'toc'); + $tocFormat = Toc::class; + if (empty($tocId)) { + $nodes = $this->xpath->query('//opf:manifest/opf:item[@properties="nav"]'); + $tocId = static::getAttr($nodes, 'id'); + $tocFormat = Nav::class; + } + + $manifest = $this->getManifest(); + + if (!isset($manifest[$tocId])) { + throw new Exception('TOC or NAV item referenced in spine missing in manifest!'); + } + + $this->spine = new Spine($manifest[$tocId], $tocFormat); + + /** @var EpubDomElement $spineNode */ + $spineNode = $this->xpath->query('//opf:spine')->item(0); + + $itemRefNodes = $spineNode->getElementsByTagName('itemref'); + foreach ($itemRefNodes as $itemRef) { + /** @var EpubDomElement $itemRef */ + $id = $itemRef->getAttribute('idref'); + if (!isset($manifest[$id])) { + throw new Exception("Item $id referenced in spine missing in manifest!"); + } + // Link the item from the manifest to the spine. + $this->spine->appendItem($manifest[$id]); + } + + return $this->spine; + } + + /** + * Get the table of contents structure of this EPUB. + * + * @return Toc|Nav + * @throws Exception + */ + public function getToc() + { + if ($this->tocnav) { + return $this->tocnav; + } + + // @todo support Nav structure as well, see initSpineComponent + if ($this->getSpine()->getTocFormat() === Nav::class) { + throw new Exception('TODO: support NAV structure as well'); + } + + $tocpath = $this->getFullPath($this->getSpine()->getTocItem()->getHref()); + $data = $this->zip->FileRead($tocpath); + $toc = new DOMDocument(); + $toc->registerNodeClass(DOMElement::class, EpubDomElement::class); + $toc->loadXML($data); + $xpath = new EpubDomXPath($toc); + //$rootNamespace = $toc->lookupNamespaceUri($toc->namespaceURI); + //$xpath->registerNamespace('x', $rootNamespace); + + $titleNode = $xpath->query('//ncx:docTitle/ncx:text')->item(0); + $title = $titleNode ? $titleNode->nodeValue : ''; + $authorNode = $xpath->query('//ncx:docAuthor/ncx:text')->item(0); + $author = $authorNode ? $authorNode->nodeValue : ''; + $this->tocnav = new Toc($title, $author); + + $navPointNodes = $xpath->query('//ncx:navMap/ncx:navPoint'); + + $this->loadNavPoints($navPointNodes, $this->tocnav->getNavMap(), $xpath); + + return $this->tocnav; + } + + /** + * Load navigation points from TOC XML DOM into TOC object structure. + * + * @param DOMNodeList<EPubDomElement> $navPointNodes List of nodes to load from. + * @param TocNavPointList $navPointList List structure to load into. + * @param EpubDomXPath $xp The XPath of the TOC document. + * @return void + */ + protected static function loadNavPoints(DOMNodeList $navPointNodes, TocNavPointList $navPointList, EpubDomXPath $xp) + { + foreach ($navPointNodes as $navPointNode) { + /** @var EpubDomElement $navPointNode */ + $id = $navPointNode->getAttribute('id'); + $class = $navPointNode->getAttribute('class'); + $playOrder = (int) $navPointNode->getAttribute('playOrder'); + $labelTextNode = $xp->query('ncx:navLabel/ncx:text', $navPointNode)->item(0); + $label = $labelTextNode ? $labelTextNode->nodeValue : ''; + /** @var EpubDomElement|null $contentNode */ + $contentNode = $xp->query('ncx:content', $navPointNode)->item(0); + $contentSource = $contentNode ? $contentNode->getAttribute('src') : ''; + $navPoint = new TocNavPoint($id, $class, $playOrder, $label, $contentSource); + $navPointList->append($navPoint); + $childNavPointNodes = $xp->query('ncx:navPoint', $navPointNode); + $childNavPoints = $navPoint->getChildren(); + + static::loadNavPoints($childNavPointNodes, $childNavPoints, $xp); + } + } + + /** + * Summary of getNav + * @return Toc|Nav + */ + public function getNav() + { + if ($this->tocnav) { + return $this->tocnav; + } + + $navpath = $this->getFullPath($this->getSpine()->getTocItem()->getHref()); + $data = $this->zip->FileRead($navpath); + $nav = new DOMDocument(); + $nav->registerNodeClass(DOMElement::class, EpubDomElement::class); + $nav->loadXML($data); + $xpath = new EpubDomXPath($nav); + $rootNamespace = $nav->lookupNamespaceUri($nav->namespaceURI); + $xpath->registerNamespace('x', $rootNamespace); + + // nav documents don't contain mandatory title or author - look in main doc + $title = $this->getTitle(); + $author = implode(', ', $this->getAuthors()); + $this->tocnav = new Nav($title, $author); + + $toc = $xpath->query('//x:nav[@epub:type="toc"]')->item(0); + $navListNodes = $xpath->query('x:ol/x:li', $toc); + if ($navListNodes->length > 0) { + $this->loadNavList($navListNodes, $this->tocnav->getNavMap(), $xpath); + } + + return $this->tocnav; + } + + /** + * Load navigation points from NAV XML DOM into NAV object structure. + * + * @param DOMNodeList<EPubDomElement> $navListNodes List of nodes to load from. + * @param TocNavPointList $navPointList List structure to load into. + * @param EpubDomXPath $xp The XPath of the NAV document. + * @param int $depth Current depth of this list (recursive) + * @param int $order Current start order for this list + * @return void + */ + protected static function loadNavList(DOMNodeList $navListNodes, TocNavPointList $navPointList, EpubDomXPath $xp, int $depth = 0, int $order = 0) + { + // h1 - h6 are supported as title for the list + $className = 'h' . strval($depth + 1); + if ($depth > 5) { + throw new Exception("We're at maximum depth for NAV DOC here!?"); + } + foreach ($navListNodes as $navPointNode) { + $order += 1; + $nodes = $xp->query('x:a', $navPointNode); + $label = trim($nodes->item(0)->nodeValue); + if (empty($label)) { + // do we have an image with title or alt available? + $images = $xp->query('x:a/x:img', $navPointNode); + if ($images->length) { + $label = static::getAttr($images, 'alt'); + if (empty($label)) { + $label = static::getAttr($images, 'title'); + } + } + } + $contentSource = static::getAttr($nodes, 'href'); + /** @var EpubDomElement $navPointNode */ + $id = $navPointNode->getAttribute('id'); + $class = $className; + $playOrder = $order; + $navPoint = new TocNavPoint($id, $class, $playOrder, $label, $contentSource); + $navPointList->append($navPoint); + $childNavPointNodes = $xp->query('x:ol/x:li', $navPointNode); + $childNavPoints = $navPoint->getChildren(); + + static::loadNavList($childNavPointNodes, $childNavPoints, $xp, $depth + 1, $order); + } + } + + /** + * Extract the contents of this EPUB. + * + * This concatenates contents of items according to their order in the spine. + * + * @param bool $keepMarkup Whether to keep the XHTML markup rather than extracted plain text. + * @param float $fraction If less than 1, only the respective part from the beginning of the book is extracted. + * @return string The contents of this EPUB. + * @throws Exception + */ + public function getContents($keepMarkup = false, $fraction = 1.0) + { + $contents = ''; + if ($fraction < 1) { + $totalSize = 0; + foreach ($this->getSpine() as $item) { + $totalSize += $item->getSize(); + } + $fractionSize = $totalSize * $fraction; + $contentsSize = 0; + foreach ($this->spine as $item) { + $itemSize = $item->getSize(); + if ($contentsSize + $itemSize > $fractionSize) { + break; + } + $contentsSize += $itemSize; + $contents .= $item->getContents(null, null, $keepMarkup); + } + } else { + foreach ($this->getSpine() as $item) { + $contents .= $item->getContents(null, null, $keepMarkup); + } + } + + return $contents; + } + + /** + * Build an XPath expression to select certain nodes in the metadata section. + * + * @param string $element The node name of the elements to select. + * @param string $attribute If set, the attribute required in the element. + * @param string|array<string> $value If set, the value of the above named attribute. If an array is given + * all of its values will be allowed in the selector. + * @param bool $caseSensitive If false, attribute values are matched case insensitively. + * (This is not completely true, as only full upper or lower case strings are matched, not mixed case. + * A lower-case function is missing in XPath 1.0.) + * @return string + */ + protected static function buildMetaXPath($element, $attribute, $value, $caseSensitive = true) + { + $xpath = '//opf:metadata/' . $element; + if ($attribute) { + $xpath .= "[@$attribute"; + if ($value) { + $values = is_array($value) ? $value : [$value]; + if (!$caseSensitive) { + $temp = []; + foreach ($values as $item) { + $temp[] = strtolower($item); + $temp[] = strtoupper($item); + } + $values = $temp; + } + + $xpath .= '="'; + $xpath .= implode("\" or @$attribute=\"", $values); + $xpath .= '"'; + } + $xpath .= ']'; + } + + return $xpath; + } + + /** + * Load an XML file from the EPUB/ZIP archive into a new XPath object. + * + * @param string $path The XML file to load from the ZIP archive. + * @return EpubDomXPath The XPath representation of the XML file. + * @throws Exception If the given path could not be read. + */ + protected function loadXPathFromItem($path) + { + $data = $this->zip->FileRead($path); + if (!$data) { + throw new Exception("Failed to read from EPUB container: $path."); + } + $xml = new DOMDocument(); + $xml->registerNodeClass(DOMElement::class, EpubDomElement::class); + $xml->loadXML($data); + + return new EpubDomXPath($xml); + } + + /** + * Get the stat entries for all files in a ZIP file + * + * @param string $file|null Path to a ZIP file or null for current file + * @return array<mixed> (filename => details of the entry) + */ + public function getZipEntries($file = null) + { + $file ??= $this->file; + $entries = []; + + $zip = new ZipArchive(); + $result = $zip->open($file, ZipArchive::RDONLY); + if ($result !== true) { + throw new Exception("Unable to open file", $result); + } + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $entries[$stat['name']] = $stat; + } + $zip->close(); + + return $entries; + } + + /** + * Map the items of a ZIP file to their respective file sizes. + * + * @param string $file|null Path to a ZIP file or null for current ZIP file + * @return array<mixed> (filename => file size) + */ + protected function loadSizeMap($file = null) + { + $entries = $this->getZipEntries($file); + + $sizeMap = []; + foreach ($entries as $filename => $entry) { + $sizeMap[$filename] = $entry['size']; + } + + return $sizeMap; + } + + /** + * @return int + */ + public function getImageCount() + { + $entries = $this->getZipEntries(); + $images = array_filter($entries, static function ($k) { + return preg_match('/(.jpeg|.jpg|.png|.gif)/', $k); + }, ARRAY_FILTER_USE_KEY); + + return count($images); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Other.php b/vendor/mikespub/php-epub-meta/src/Other.php new file mode 100644 index 000000000..6d60197ce --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Other.php @@ -0,0 +1,121 @@ +<?php +/** + * Representation of an EPUB document. + * + * @author Andreas Gohr <andi@splitbrain.org> © 2012 + * @author Simon Schrape <simon@epubli.com> © 2015 + */ + +//namespace Epubli\Epub; + +namespace SebLucas\EPubMeta; + +use SebLucas\EPubMeta\Dom\Element as EpubDomElement; +use SebLucas\EPubMeta\Dom\XPath as EpubDomXPath; +use SebLucas\EPubMeta\Data\Manifest; +use SebLucas\EPubMeta\Contents\Spine; +use SebLucas\EPubMeta\Contents\Toc; +use DOMDocument; +use DOMElement; +use Exception; +use InvalidArgumentException; +use ZipArchive; + +/** + * @todo These are the methods that haven't been integrated with EPub here... + */ +class Other extends EPub +{ + /** + * A simple setter for simple meta attributes + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to set + * @param string $value New node value + * @param bool|string $attribute Attribute name + * @param bool|string $attributeValue Attribute value + * @param bool $caseSensitive + */ + protected function setMeta($item, $value, $attribute = false, $attributeValue = false, $caseSensitive = true) + { + $xpath = $this->buildMetaXPath($item, $attribute, $attributeValue, $caseSensitive); + + // set value + $nodes = $this->xpath->query($xpath); + if ($nodes->length == 1) { + /** @var EpubDomElement $node */ + $node = $nodes->item(0); + if ($value === '') { + // the user wants to empty this value -> delete the node + $node->delete(); + } else { + // replace value + $node->nodeValueUnescaped = $value; + } + } else { + // if there are multiple matching nodes for some reason delete + // them. we'll replace them all with our own single one + foreach ($nodes as $node) { + /** @var EpubDomElement $node */ + $node->delete(); + } + // re-add them + if ($value) { + $parent = $this->xpath->query('//opf:metadata')->item(0); + $node = new EpubDomElement($item, $value); + $node = $parent->appendChild($node); + if ($attribute) { + if (is_array($attributeValue)) { + // use first given value for new attribute + $attributeValue = reset($attributeValue); + } + $node->setAttrib($attribute, $attributeValue); + } + } + } + + $this->sync(); + } + + /** + * A simple getter for simple meta attributes + * + * It should only be used for attributes that are expected to be unique + * + * @param string $item XML node to get + * @param bool|string $att Attribute name + * @param bool|string $aval Attribute value + * @param bool $caseSensitive + * @return string + */ + protected function getMeta($item, $att = false, $aval = false, $caseSensitive = true) + { + $xpath = $this->buildMetaXPath($item, $att, $aval, $caseSensitive); + + // get value + $nodes = $this->xpath->query($xpath); + if ($nodes->length) { + /** @var EpubDomElement $node */ + $node = $nodes->item(0); + + return $node->nodeValueUnescaped; + } else { + return ''; + } + } + + /** + * Sync XPath object with updated DOM. + */ + protected function sync() + { + $dom = $this->xpath->document; + $dom->loadXML($dom->saveXML()); + $this->xpath = new EpubDomXPath($dom); + // reset structural members + $this->manifest = null; + $this->spine = null; + $this->tocnav = null; + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Tools/HtmlTools.php b/vendor/mikespub/php-epub-meta/src/Tools/HtmlTools.php new file mode 100644 index 000000000..f905d265a --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Tools/HtmlTools.php @@ -0,0 +1,97 @@ +<?php + +namespace SebLucas\EPubMeta\Tools; + +/** + * From Epubli\Common\Tools - see https://github.com/epubli/common + * @author Epubli Developers <devs@epubli.com> + */ +class HtmlTools +{ + /** + * @param string $html + * @return string + */ + public static function convertEntitiesNamedToNumeric($html) + { + return strtr($html, include(__DIR__ . '/htmlEntityMap.php')); + } + + /** + * @param string $name + * @return bool + */ + public static function isBlockLevelElement($name) + { + return in_array($name, include(__DIR__ . '/htmlBlockLevelElements.php')); + } + + /** + * performs a tag-aware truncation of (html-) strings, preserving tag integrity + * @param array<string>|string $html + * @param int|string $length + * @return bool|string + */ + public static function truncate($html, $length = "20%") + { + $htmls = is_array($html) ? $html : [$html]; + foreach ($htmls as &$htmlString) { + if (is_string($length)) { + $length = trim($length); + /* interpret percentage value */ + if (substr($length, -1) == '%') { + $length = (int) (strlen($htmlString) * intval(substr($length, 0, -1)) / 100); + } + } + $htmlString = substr($htmlString, 0, $length); + /* eliminate trailing truncated tag fragment if present */ + $htmlString = preg_replace('/<[^>]*$/is', '', $htmlString); + } + + return is_array($html) ? $htmls : array_pop($htmls); + } + + /** + * strips all occurring html tags from $html (which can either be a string or an array of strings), + * preserving all content enclosed by all tags in $keep and + * dumping the content residing in all tags listed in $drop + * @param array<string>|string $html + * @param array<string> $keep + * @param array<string> $drop + * @return array<string>|string + */ + public static function stripHtmlTags( + $html, + $keep = + ['title', 'br', 'p', 'h1','h2','h3','h4','h5','span','div','i','strong','b', 'table', 'td', 'th', 'tr'], + $drop = + ['head','style'] + ) { + $htmls = is_array($html) ? $html : [$html]; + foreach ($htmls as &$htmlString) { + foreach ($drop as $dumpTag) { + $htmlString = preg_replace("/<$dumpTag.*$dumpTag>/is", "\n", $htmlString); + } + $htmlString = preg_replace("/[\n\r ]{2,}/i", "\n", $htmlString); + $htmlString = preg_replace("/[\n|\r]/i", '<br />', $htmlString); + + /* @TODO: remove style tags and only keep body content (drop head) */ + $tempFunc = function ($matches) use ($keep) { + $htmlNode = "<" . $matches[1] . ">" . strip_tags($matches[2]) . "</" . $matches[1] . ">"; + if (in_array($matches[1], $keep)) { + return " " . $htmlNode . " "; + } else { + return ""; + } + }; + + $allowedTags = implode("|", array_values($keep)); + $regExp = '@<(' . $allowedTags . ')[^>]*?>(.*?)<\/\1>@i'; + $htmlString = preg_replace_callback($regExp, $tempFunc, $htmlString); + + $htmlString = strip_tags($htmlString, "<" . implode("><", $keep) . ">"); + } + /* preserve injected variable cast type (string|array) when returning processed entity */ + return is_array($html) ? $htmls : array_pop($htmls); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Tools/ZipEdit.php b/vendor/mikespub/php-epub-meta/src/Tools/ZipEdit.php new file mode 100644 index 000000000..ff9f607cf --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Tools/ZipEdit.php @@ -0,0 +1,450 @@ +<?php +/** + * ZipEdit class + * + * @author mikespub + */ + +namespace SebLucas\EPubMeta\Tools; + +use ZipStream\ZipStream; +use DateTime; +use Exception; +use ZipArchive; + +/** + * ZipEdit class allows to edit zip files on the fly and stream them afterwards + */ +class ZipEdit +{ + public const DOWNLOAD = 1; // download (default) + public const NOHEADER = 4; // option to use with DOWNLOAD: no header is sent + public const FILE = 8; // output to file , or add from file + public const STRING = 32; // output to string, or add from string + public const MIME_TYPE = 'application/epub+zip'; + + /** @var ZipArchive|null */ + private $mZip; + /** @var array<string, mixed>|null */ + private $mEntries; + /** @var array<string, mixed> */ + private $mChanges = []; + /** @var string|null */ + private $mFileName; + private bool $mSaveMe = false; + + public function __construct() + { + $this->mZip = null; + $this->mEntries = null; + $this->mFileName = null; + } + + /** + * Destructor + */ + public function __destruct() + { + $this->Close(); + } + + /** + * Open a zip file and read it's entries + * + * @param string $inFileName + * @param int|null $inFlags + * @return boolean True if zip file has been correctly opended, else false + */ + public function Open($inFileName, $inFlags = 0) // ZipArchive::RDONLY) + { + $this->Close(); + + $this->mZip = new ZipArchive(); + $result = $this->mZip->open($inFileName, ZipArchive::RDONLY); + if ($result !== true) { + return false; + } + + $this->mFileName = $inFileName; + + $this->mEntries = []; + $this->mChanges = []; + + for ($i = 0; $i < $this->mZip->numFiles; $i++) { + $entry = $this->mZip->statIndex($i); + $fileName = $entry['name']; + $this->mEntries[$fileName] = $entry; + $this->mChanges[$fileName] = ['status' => 'unchanged']; + } + + return true; + } + + /** + * Check if a file exist in the zip entries + * + * @param string $inFileName File to search + * + * @return boolean True if the file exist, else false + */ + public function FileExists($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + return true; + } + + /** + * Read the content of a file in the zip entries + * + * @param string $inFileName File to search + * + * @return mixed File content the file exist, else false + */ + public function FileRead($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + $data = false; + + $changes = $this->mChanges[$inFileName] ?? ['status' => 'unchanged']; + switch ($changes['status']) { + case 'unchanged': + $data = $this->mZip->getFromName($inFileName); + break; + case 'added': + case 'modified': + if (isset($changes['data'])) { + $data = $changes['data']; + } elseif (isset($changes['path'])) { + $data = file_get_contents($changes['path']); + } + break; + case 'deleted': + default: + break; + } + return $data; + } + + /** + * Get a file handler to a file in the zip entries (read-only) + * + * @param string $inFileName File to search + * + * @return resource|bool File handler if the file exist, else false + */ + public function FileStream($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + // @todo streaming of added/modified data? + return $this->mZip->getStream($inFileName); + } + + /** + * Summary of FileAdd + * @param string $inFileName + * @param mixed $inData + * @return bool + */ + public function FileAdd($inFileName, $inData) + { + if (!isset($this->mZip)) { + return false; + } + + $this->mEntries[$inFileName] = [ + 'name' => $inFileName, // 'foobar/baz', + 'size' => strlen($inData), + 'mtime' => time(), // 1123164748, + ]; + $this->mChanges[$inFileName] = ['status' => 'added', 'data' => $inData]; + return true; + } + + /** + * Summary of FileAddPath + * @param string $inFileName + * @param string $inFilePath + * @return bool + */ + public function FileAddPath($inFileName, $inFilePath) + { + if (!isset($this->mZip)) { + return false; + } + + $this->mEntries[$inFileName] = [ + 'name' => $inFileName, // 'foobar/baz', + 'size' => filesize($inFilePath), + 'mtime' => filemtime($inFilePath), // 1123164748, + ]; + $this->mChanges[$inFileName] = ['status' => 'added', 'path' => $inFilePath]; + return true; + } + + /** + * Summary of FileDelete + * @param string $inFileName + * @return bool + */ + public function FileDelete($inFileName) + { + if (!$this->FileExists($inFileName)) { + return false; + } + + $this->mEntries[$inFileName]['size'] = 0; + $this->mEntries[$inFileName]['mtime'] = time(); + $this->mChanges[$inFileName] = ['status' => 'deleted']; + return true; + } + + /** + * Replace the content of a file in the zip entries + * + * @param string $inFileName File with content to replace + * @param string|bool $inData Data content to replace, or false to delete + * @return bool + */ + public function FileReplace($inFileName, $inData) + { + if (!isset($this->mZip)) { + return false; + } + + if ($inData === false) { + return $this->FileDelete($inFileName); + } + + $this->mEntries[$inFileName] ??= []; + $this->mEntries[$inFileName]['name'] = $inFileName; + $this->mEntries[$inFileName]['size'] = strlen($inData); + $this->mEntries[$inFileName]['mtime'] = time(); + $this->mChanges[$inFileName] = ['status' => 'modified', 'data' => $inData]; + return true; + } + + /** + * Return the state of the file. + * @param mixed $inFileName + * @return string|bool 'u'=unchanged, 'm'=modified, 'd'=deleted, 'a'=added, false=unknown + */ + public function FileGetState($inFileName) + { + $changes = $this->mChanges[$inFileName] ?? ['status' => false]; + return $changes['status']; + } + + /** + * Summary of FileCancelModif + * @param string $inFileName + * @param bool $ReplacedAndDeleted + * @return int + */ + public function FileCancelModif($inFileName, $ReplacedAndDeleted = true) + { + // cancel added, modified or deleted modifications on a file in the archive + // return the number of cancels + + $nbr = 0; + + $this->mChanges[$inFileName] = ['status' => 'unchanged']; + $nbr += 1; + return $nbr; + } + + /** + * Close the zip file + * + * @return void + * @throws Exception + */ + public function Close() + { + if (!isset($this->mZip)) { + return; + } + + $outFileName = $this->mFileName . '.copy'; + if ($this->mSaveMe) { + $outFileStream = fopen($outFileName, 'wb+'); + if ($outFileStream === false) { + throw new Exception('Unable to open zip copy ' . $outFileName); + } + $this->Flush(self::DOWNLOAD, $this->mFileName, self::MIME_TYPE, false, $outFileStream); + $result = fclose($outFileStream); + if ($result === false) { + throw new Exception('Unable to close zip copy ' . $outFileName); + } + } + + if (!$this->mZip->close()) { + $status = $this->mZip->getStatusString(); + $this->mZip = null; + throw new Exception($status); + } + if ($this->mSaveMe) { + $result = rename($outFileName, $this->mFileName); + if ($result === false) { + throw new Exception('Unable to rename zip copy ' . $outFileName); + } + $this->mSaveMe = false; + } + $this->mZip = null; + } + + /** + * Summary of SaveBeforeClose + * @return void + */ + public function SaveBeforeClose() + { + // Coming from EPub()->download() without fileName, called in EPub()->save() + // This comes right before EPub()->zip->close(), at which point we're lost + $this->mSaveMe = true; + } + + /** + * Summary of Flush + * @param mixed $render + * @param mixed $outFileName + * @param mixed $contentType + * @param bool $sendHeaders + * @param resource|null $outFileStream + * @return void + */ + public function Flush($render = self::DOWNLOAD, $outFileName = '', $contentType = '', $sendHeaders = true, $outFileStream = null) + { + // we don't want to close the zip file to save all changes here - probably what you needed :-) + //$this->Close(); + + $outFileName = $outFileName ?: $this->mFileName; + $contentType = $contentType ?: self::MIME_TYPE; + if ($outFileStream) { + $sendHeaders = false; + } + if (!$sendHeaders) { + $render = $render | self::NOHEADER; + } + if (($render & self::NOHEADER) !== self::NOHEADER) { + $sendHeaders = true; + } else { + $sendHeaders = false; + } + + $outZipStream = new ZipStream( + outputName: basename($outFileName), + outputStream: $outFileStream, + sendHttpHeaders: $sendHeaders, + contentType: $contentType, + ); + foreach ($this->mEntries as $fileName => $entry) { + $changes = $this->mChanges[$fileName]; + switch ($changes['status']) { + case 'unchanged': + // Automatic binding of $this + $callback = function () use ($fileName) { + // this expects a stream as result, not the actual data + return $this->mZip->getStream($fileName); + }; + $date = new DateTime(); + $date->setTimestamp($entry['mtime']); + $outZipStream->addFileFromCallback( + fileName: $fileName, + exactSize: $entry['size'], + lastModificationDateTime: $date, + callback: $callback, + ); + break; + case 'added': + case 'modified': + if (isset($changes['data'])) { + $outZipStream->addFile( + fileName: $fileName, + data: $changes['data'], + ); + } elseif (isset($changes['path'])) { + $outZipStream->addFileFromPath( + fileName: $fileName, + path: $changes['path'], + ); + } + break; + case 'deleted': + default: + break; + } + } + + $outZipStream->finish(); + } + + /** + * Summary of copyTest + * @param string $inFileName + * @param string $outFileName + * @return void + */ + public static function copyTest($inFileName, $outFileName) + { + $inZipFile = new ZipArchive(); + $result = $inZipFile->open($inFileName, ZipArchive::RDONLY); + if ($result !== true) { + throw new Exception('Unable to open zip file ' . $inFileName); + } + + $entries = []; + for ($i = 0; $i < $inZipFile->numFiles; $i++) { + $entry = $inZipFile->statIndex($i); + $fileName = $entry['name']; + $entries[$fileName] = $entry; + } + + // see ZipStreamTest.php + $outFileStream = fopen($outFileName, 'wb+'); + + $outZipStream = new ZipStream( + outputName: basename($outFileName), + outputStream: $outFileStream, + sendHttpHeaders: false, + ); + foreach ($entries as $fileName => $entry) { + $date = new DateTime(); + $date->setTimestamp($entry['mtime']); + // does not work in v2 - the zip stream is not seekable, but ZipStream checks for it in Stream.php + // does work in v3 - implemented using addFileFromCallback, so we might as well use that :-) + $outZipStream->addFileFromCallback( + fileName: $fileName, + exactSize: $entry['size'], + lastModificationDateTime: $date, + callback: function () use ($inZipFile, $fileName) { + // this expects a stream as result, not the actual data + return $inZipFile->getStream($fileName); + }, + ); + } + + $outZipStream->finish(); + fclose($outFileStream); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Tools/ZipFile.php b/vendor/mikespub/php-epub-meta/src/Tools/ZipFile.php new file mode 100644 index 000000000..b083b0c4f --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Tools/ZipFile.php @@ -0,0 +1,343 @@ +<?php +/** + * ZipFile class + * + * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) + * @author Didier Corbière <contact@atoll-digital-library.org> + * @author mikespub + */ + +namespace SebLucas\EPubMeta\Tools; + +use Exception; +use ZipArchive; + +/** + * ZipFile class allows to open files inside a zip file with the standard php zip functions + * + * This class also supports adding/replacing/deleting files inside the zip file, but changes + * will *not* be reflected correctly until you close the zip file, and open it again if needed + * + * Note: this is not meant to handle a massive amount of files inside generic archive files. + * It is specifically meant for EPUB files which typically contain hundreds/thousands of pages, + * not millions or more. And any changes you make are kept in memory, so don't re-write every + * page of War and Peace either - better to unzip that locally and then re-zip it afterwards. + */ +class ZipFile +{ + public const DOWNLOAD = 1; // download (default) + public const NOHEADER = 4; // option to use with DOWNLOAD: no header is sent + public const FILE = 8; // output to file , or add from file + public const STRING = 32; // output to string, or add from string + public const MIME_TYPE = 'application/epub+zip'; + + /** @var ZipArchive|null */ + protected $mZip; + /** @var array<string, mixed>|null */ + protected $mEntries; + /** @var array<string, mixed> */ + protected $mChanges = []; + /** @var string|null */ + protected $mFileName; + + public function __construct() + { + $this->mZip = null; + $this->mEntries = null; + $this->mFileName = null; + } + + /** + * Destructor + */ + public function __destruct() + { + $this->Close(); + } + + /** + * Open a zip file and read it's entries + * + * @param string $inFileName + * @param int|null $inFlags + * @return boolean True if zip file has been correctly opended, else false + */ + public function Open($inFileName, $inFlags = 0) // ZipArchive::RDONLY) + { + $this->Close(); + $inFileName = realpath($inFileName); + + $this->mZip = new ZipArchive(); + $result = $this->mZip->open($inFileName, $inFlags); + if ($result !== true) { + return false; + } + + $this->mFileName = $inFileName; + + $this->mEntries = []; + $this->mChanges = []; + + for ($i = 0; $i < $this->mZip->numFiles; $i++) { + $entry = $this->mZip->statIndex($i); + $fileName = $entry['name']; + $this->mEntries[$fileName] = $entry; + $this->mChanges[$fileName] = ['status' => 'unchanged']; + } + + return true; + } + + /** + * Check if a file exist in the zip entries + * + * @param string $inFileName File to search + * + * @return boolean True if the file exist, else false + */ + public function FileExists($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + return true; + } + + /** + * Read the content of a file in the zip entries + * + * @param string $inFileName File to search + * + * @return mixed File content the file exist, else false + */ + public function FileRead($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + $data = false; + + $changes = $this->mChanges[$inFileName] ?? ['status' => 'unchanged']; + switch ($changes['status']) { + case 'unchanged': + $data = $this->mZip->getFromName($inFileName); + break; + case 'added': + case 'modified': + if (isset($changes['data'])) { + $data = $changes['data']; + } elseif (isset($changes['path'])) { + $data = file_get_contents($changes['path']); + } + break; + case 'deleted': + default: + break; + } + return $data; + } + + /** + * Get a file handler to a file in the zip entries (read-only) + * + * @param string $inFileName File to search + * + * @return resource|bool File handler if the file exist, else false + */ + public function FileStream($inFileName) + { + if (!isset($this->mZip)) { + return false; + } + + if (!isset($this->mEntries[$inFileName])) { + return false; + } + + // @todo streaming of added/modified data? + return $this->mZip->getStream($inFileName); + } + + /** + * Summary of FileAdd + * @param string $inFileName + * @param mixed $inData + * @return bool + */ + public function FileAdd($inFileName, $inData) + { + if (!isset($this->mZip)) { + return false; + } + + if (!$this->mZip->addFromString($inFileName, $inData)) { + return false; + } + $this->mEntries[$inFileName] = $this->mZip->statName($inFileName); + $this->mChanges[$inFileName] = ['status' => 'added', 'data' => $inData]; + return true; + } + + /** + * Summary of FileAddPath + * @param string $inFileName + * @param string $inFilePath + * @return mixed + */ + public function FileAddPath($inFileName, $inFilePath) + { + if (!isset($this->mZip)) { + return false; + } + + if (!$this->mZip->addFile($inFilePath, $inFileName)) { + return false; + } + $this->mEntries[$inFileName] = $this->mZip->statName($inFileName); + $this->mChanges[$inFileName] = ['status' => 'added', 'path' => $inFilePath]; + return true; + } + + /** + * Summary of FileDelete + * @param string $inFileName + * @return bool + */ + public function FileDelete($inFileName) + { + if (!$this->FileExists($inFileName)) { + return false; + } + + if (!$this->mZip->deleteName($inFileName)) { + return false; + } + unset($this->mEntries[$inFileName]); + $this->mChanges[$inFileName] = ['status' => 'deleted']; + return true; + } + + /** + * Replace the content of a file in the zip entries + * + * @param string $inFileName File with content to replace + * @param string|bool $inData Data content to replace, or false to delete + * @return bool + */ + public function FileReplace($inFileName, $inData) + { + if (!isset($this->mZip)) { + return false; + } + + if ($inData === false) { + return $this->FileDelete($inFileName); + } + + if (!$this->mZip->addFromString($inFileName, $inData)) { + return false; + } + $this->mEntries[$inFileName] = $this->mZip->statName($inFileName); + $this->mChanges[$inFileName] = ['status' => 'modified', 'data' => $inData]; + return true; + } + + /** + * Return the state of the file. + * @param mixed $inFileName + * @return string|bool 'u'=unchanged, 'm'=modified, 'd'=deleted, 'a'=added, false=unknown + */ + public function FileGetState($inFileName) + { + $changes = $this->mChanges[$inFileName] ?? ['status' => false]; + return $changes['status']; + } + + /** + * Summary of FileCancelModif + * @param string $inFileName + * @param bool $ReplacedAndDeleted + * @return int + */ + public function FileCancelModif($inFileName, $ReplacedAndDeleted = true) + { + // cancel added, modified or deleted modifications on a file in the archive + // return the number of cancels + + $nbr = 0; + + if (!$this->mZip->unchangeName($inFileName)) { + return $nbr; + } + $nbr += 1; + $this->mChanges[$inFileName] = ['status' => 'unchanged']; + return $nbr; + } + + /** + * Close the zip file + * + * @return void + * @throws Exception + */ + public function Close() + { + if (!isset($this->mZip)) { + return; + } + + if (!$this->mZip->close()) { + $status = $this->mZip->getStatusString(); + $this->mZip = null; + throw new Exception($status); + } + $this->mZip = null; + } + + /** + * Summary of Flush + * @param mixed $render + * @param mixed $outFileName + * @param mixed $contentType + * @param bool $sendHeaders + * @return void + */ + public function Flush($render = self::DOWNLOAD, $outFileName = '', $contentType = '', $sendHeaders = true) + { + // we need to close the zip file to save all changes here - probably not what you wanted :-() + $this->Close(); + + $outFileName = $outFileName ?: $this->mFileName; + $contentType = $contentType ?: static::MIME_TYPE; + if (!$sendHeaders) { + $render = $render | static::NOHEADER; + } + $inFilePath = realpath($this->mFileName); + + if (($render & static::NOHEADER) !== static::NOHEADER) { + $expires = 60 * 60 * 24 * 14; + header('Pragma: public'); + header('Cache-Control: max-age=' . $expires); + header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'); + + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . basename($outFileName) . '"'); + + // see fetch.php for use of Config::get('x_accel_redirect') + header('Content-Length: ' . filesize($inFilePath)); + //header(Config::get('x_accel_redirect') . ': ' . $inFilePath); + } + + readfile($inFilePath); + } +} diff --git a/vendor/mikespub/php-epub-meta/src/Tools/htmlBlockLevelElements.php b/vendor/mikespub/php-epub-meta/src/Tools/htmlBlockLevelElements.php new file mode 100644 index 000000000..6e96a6b0f --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Tools/htmlBlockLevelElements.php @@ -0,0 +1,42 @@ +<?php +/** + * List of HTML block level elements. + * Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements + */ +return [ + 'address', + 'article', + 'aside', + 'audio', + 'video', + 'blockquote', + 'canvas', + 'dd', + 'div', + 'dl', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hgroup', + 'hr', + 'li', + 'noscript', + 'ol', + 'output', + 'p', + 'pre', + 'section', + 'table', + 'tfoot', + 'ul', + 'video', +]; diff --git a/vendor/mikespub/php-epub-meta/src/Tools/htmlEntityMap.php b/vendor/mikespub/php-epub-meta/src/Tools/htmlEntityMap.php new file mode 100644 index 000000000..6bc3f4653 --- /dev/null +++ b/vendor/mikespub/php-epub-meta/src/Tools/htmlEntityMap.php @@ -0,0 +1,260 @@ +<?php +/** + * Maps every HTML named entity to its numeric equivalent. + * Source: http://stackoverflow.com/questions/11176752/converting-named-html-entities-to-numeric-html-entities#answer-11179875 + */ +return [ +' ' => ' ', # no-break space = non-breaking space, U+00A0 ISOnum +'¡' => '¡', # inverted exclamation mark, U+00A1 ISOnum +'¢' => '¢', # cent sign, U+00A2 ISOnum +'£' => '£', # pound sign, U+00A3 ISOnum +'¤' => '¤', # currency sign, U+00A4 ISOnum +'¥' => '¥', # yen sign = yuan sign, U+00A5 ISOnum +'¦' => '¦', # broken bar = broken vertical bar, U+00A6 ISOnum +'§' => '§', # section sign, U+00A7 ISOnum +'¨' => '¨', # diaeresis = spacing diaeresis, U+00A8 ISOdia +'©' => '©', # copyright sign, U+00A9 ISOnum +'ª' => 'ª', # feminine ordinal indicator, U+00AA ISOnum +'«' => '«', # left-pointing double angle quotation mark = left pointing guillemet, U+00AB ISOnum +'¬' => '¬', # not sign, U+00AC ISOnum +'­' => '­', # soft hyphen = discretionary hyphen, U+00AD ISOnum +'®' => '®', # registered sign = registered trade mark sign, U+00AE ISOnum +'¯' => '¯', # macron = spacing macron = overline = APL overbar, U+00AF ISOdia +'°' => '°', # degree sign, U+00B0 ISOnum +'±' => '±', # plus-minus sign = plus-or-minus sign, U+00B1 ISOnum +'²' => '²', # superscript two = superscript digit two = squared, U+00B2 ISOnum +'³' => '³', # superscript three = superscript digit three = cubed, U+00B3 ISOnum +'´' => '´', # acute accent = spacing acute, U+00B4 ISOdia +'µ' => 'µ', # micro sign, U+00B5 ISOnum +'¶' => '¶', # pilcrow sign = paragraph sign, U+00B6 ISOnum +'·' => '·', # middle dot = Georgian comma = Greek middle dot, U+00B7 ISOnum +'¸' => '¸', # cedilla = spacing cedilla, U+00B8 ISOdia +'¹' => '¹', # superscript one = superscript digit one, U+00B9 ISOnum +'º' => 'º', # masculine ordinal indicator, U+00BA ISOnum +'»' => '»', # right-pointing double angle quotation mark = right pointing guillemet, U+00BB ISOnum +'¼' => '¼', # vulgar fraction one quarter = fraction one quarter, U+00BC ISOnum +'½' => '½', # vulgar fraction one half = fraction one half, U+00BD ISOnum +'¾' => '¾', # vulgar fraction three quarters = fraction three quarters, U+00BE ISOnum +'¿' => '¿', # inverted question mark = turned question mark, U+00BF ISOnum +'À' => 'À', # latin capital letter A with grave = latin capital letter A grave, U+00C0 ISOlat1 +'Á' => 'Á', # latin capital letter A with acute, U+00C1 ISOlat1 +'Â' => 'Â', # latin capital letter A with circumflex, U+00C2 ISOlat1 +'Ã' => 'Ã', # latin capital letter A with tilde, U+00C3 ISOlat1 +'Ä' => 'Ä', # latin capital letter A with diaeresis, U+00C4 ISOlat1 +'Å' => 'Å', # latin capital letter A with ring above = latin capital letter A ring, U+00C5 ISOlat1 +'Æ' => 'Æ', # latin capital letter AE = latin capital ligature AE, U+00C6 ISOlat1 +'Ç' => 'Ç', # latin capital letter C with cedilla, U+00C7 ISOlat1 +'È' => 'È', # latin capital letter E with grave, U+00C8 ISOlat1 +'É' => 'É', # latin capital letter E with acute, U+00C9 ISOlat1 +'Ê' => 'Ê', # latin capital letter E with circumflex, U+00CA ISOlat1 +'Ë' => 'Ë', # latin capital letter E with diaeresis, U+00CB ISOlat1 +'Ì' => 'Ì', # latin capital letter I with grave, U+00CC ISOlat1 +'Í' => 'Í', # latin capital letter I with acute, U+00CD ISOlat1 +'Î' => 'Î', # latin capital letter I with circumflex, U+00CE ISOlat1 +'Ï' => 'Ï', # latin capital letter I with diaeresis, U+00CF ISOlat1 +'Ð' => 'Ð', # latin capital letter ETH, U+00D0 ISOlat1 +'Ñ' => 'Ñ', # latin capital letter N with tilde, U+00D1 ISOlat1 +'Ò' => 'Ò', # latin capital letter O with grave, U+00D2 ISOlat1 +'Ó' => 'Ó', # latin capital letter O with acute, U+00D3 ISOlat1 +'Ô' => 'Ô', # latin capital letter O with circumflex, U+00D4 ISOlat1 +'Õ' => 'Õ', # latin capital letter O with tilde, U+00D5 ISOlat1 +'Ö' => 'Ö', # latin capital letter O with diaeresis, U+00D6 ISOlat1 +'×' => '×', # multiplication sign, U+00D7 ISOnum +'Ø' => 'Ø', # latin capital letter O with stroke = latin capital letter O slash, U+00D8 ISOlat1 +'Ù' => 'Ù', # latin capital letter U with grave, U+00D9 ISOlat1 +'Ú' => 'Ú', # latin capital letter U with acute, U+00DA ISOlat1 +'Û' => 'Û', # latin capital letter U with circumflex, U+00DB ISOlat1 +'Ü' => 'Ü', # latin capital letter U with diaeresis, U+00DC ISOlat1 +'Ý' => 'Ý', # latin capital letter Y with acute, U+00DD ISOlat1 +'Þ' => 'Þ', # latin capital letter THORN, U+00DE ISOlat1 +'ß' => 'ß', # latin small letter sharp s = ess-zed, U+00DF ISOlat1 +'à' => 'à', # latin small letter a with grave = latin small letter a grave, U+00E0 ISOlat1 +'á' => 'á', # latin small letter a with acute, U+00E1 ISOlat1 +'â' => 'â', # latin small letter a with circumflex, U+00E2 ISOlat1 +'ã' => 'ã', # latin small letter a with tilde, U+00E3 ISOlat1 +'ä' => 'ä', # latin small letter a with diaeresis, U+00E4 ISOlat1 +'å' => 'å', # latin small letter a with ring above = latin small letter a ring, U+00E5 ISOlat1 +'æ' => 'æ', # latin small letter ae = latin small ligature ae, U+00E6 ISOlat1 +'ç' => 'ç', # latin small letter c with cedilla, U+00E7 ISOlat1 +'è' => 'è', # latin small letter e with grave, U+00E8 ISOlat1 +'é' => 'é', # latin small letter e with acute, U+00E9 ISOlat1 +'ê' => 'ê', # latin small letter e with circumflex, U+00EA ISOlat1 +'ë' => 'ë', # latin small letter e with diaeresis, U+00EB ISOlat1 +'ì' => 'ì', # latin small letter i with grave, U+00EC ISOlat1 +'í' => 'í', # latin small letter i with acute, U+00ED ISOlat1 +'î' => 'î', # latin small letter i with circumflex, U+00EE ISOlat1 +'ï' => 'ï', # latin small letter i with diaeresis, U+00EF ISOlat1 +'ð' => 'ð', # latin small letter eth, U+00F0 ISOlat1 +'ñ' => 'ñ', # latin small letter n with tilde, U+00F1 ISOlat1 +'ò' => 'ò', # latin small letter o with grave, U+00F2 ISOlat1 +'ó' => 'ó', # latin small letter o with acute, U+00F3 ISOlat1 +'ô' => 'ô', # latin small letter o with circumflex, U+00F4 ISOlat1 +'õ' => 'õ', # latin small letter o with tilde, U+00F5 ISOlat1 +'ö' => 'ö', # latin small letter o with diaeresis, U+00F6 ISOlat1 +'÷' => '÷', # division sign, U+00F7 ISOnum +'ø' => 'ø', # latin small letter o with stroke, = latin small letter o slash, U+00F8 ISOlat1 +'ù' => 'ù', # latin small letter u with grave, U+00F9 ISOlat1 +'ú' => 'ú', # latin small letter u with acute, U+00FA ISOlat1 +'û' => 'û', # latin small letter u with circumflex, U+00FB ISOlat1 +'ü' => 'ü', # latin small letter u with diaeresis, U+00FC ISOlat1 +'ý' => 'ý', # latin small letter y with acute, U+00FD ISOlat1 +'þ' => 'þ', # latin small letter thorn, U+00FE ISOlat1 +'ÿ' => 'ÿ', # latin small letter y with diaeresis, U+00FF ISOlat1 +'ƒ' => 'ƒ', # latin small f with hook = function = florin, U+0192 ISOtech +'Α' => 'Α', # greek capital letter alpha, U+0391 +'Β' => 'Β', # greek capital letter beta, U+0392 +'Γ' => 'Γ', # greek capital letter gamma, U+0393 ISOgrk3 +'Δ' => 'Δ', # greek capital letter delta, U+0394 ISOgrk3 +'Ε' => 'Ε', # greek capital letter epsilon, U+0395 +'Ζ' => 'Ζ', # greek capital letter zeta, U+0396 +'Η' => 'Η', # greek capital letter eta, U+0397 +'Θ' => 'Θ', # greek capital letter theta, U+0398 ISOgrk3 +'Ι' => 'Ι', # greek capital letter iota, U+0399 +'Κ' => 'Κ', # greek capital letter kappa, U+039A +'Λ' => 'Λ', # greek capital letter lambda, U+039B ISOgrk3 +'Μ' => 'Μ', # greek capital letter mu, U+039C +'Ν' => 'Ν', # greek capital letter nu, U+039D +'Ξ' => 'Ξ', # greek capital letter xi, U+039E ISOgrk3 +'Ο' => 'Ο', # greek capital letter omicron, U+039F +'Π' => 'Π', # greek capital letter pi, U+03A0 ISOgrk3 +'Ρ' => 'Ρ', # greek capital letter rho, U+03A1 +'Σ' => 'Σ', # greek capital letter sigma, U+03A3 ISOgrk3 +'Τ' => 'Τ', # greek capital letter tau, U+03A4 +'Υ' => 'Υ', # greek capital letter upsilon, U+03A5 ISOgrk3 +'Φ' => 'Φ', # greek capital letter phi, U+03A6 ISOgrk3 +'Χ' => 'Χ', # greek capital letter chi, U+03A7 +'Ψ' => 'Ψ', # greek capital letter psi, U+03A8 ISOgrk3 +'Ω' => 'Ω', # greek capital letter omega, U+03A9 ISOgrk3 +'α' => 'α', # greek small letter alpha, U+03B1 ISOgrk3 +'β' => 'β', # greek small letter beta, U+03B2 ISOgrk3 +'γ' => 'γ', # greek small letter gamma, U+03B3 ISOgrk3 +'δ' => 'δ', # greek small letter delta, U+03B4 ISOgrk3 +'ε' => 'ε', # greek small letter epsilon, U+03B5 ISOgrk3 +'ζ' => 'ζ', # greek small letter zeta, U+03B6 ISOgrk3 +'η' => 'η', # greek small letter eta, U+03B7 ISOgrk3 +'θ' => 'θ', # greek small letter theta, U+03B8 ISOgrk3 +'ι' => 'ι', # greek small letter iota, U+03B9 ISOgrk3 +'κ' => 'κ', # greek small letter kappa, U+03BA ISOgrk3 +'λ' => 'λ', # greek small letter lambda, U+03BB ISOgrk3 +'μ' => 'μ', # greek small letter mu, U+03BC ISOgrk3 +'ν' => 'ν', # greek small letter nu, U+03BD ISOgrk3 +'ξ' => 'ξ', # greek small letter xi, U+03BE ISOgrk3 +'ο' => 'ο', # greek small letter omicron, U+03BF NEW +'π' => 'π', # greek small letter pi, U+03C0 ISOgrk3 +'ρ' => 'ρ', # greek small letter rho, U+03C1 ISOgrk3 +'ς' => 'ς', # greek small letter final sigma, U+03C2 ISOgrk3 +'σ' => 'σ', # greek small letter sigma, U+03C3 ISOgrk3 +'τ' => 'τ', # greek small letter tau, U+03C4 ISOgrk3 +'υ' => 'υ', # greek small letter upsilon, U+03C5 ISOgrk3 +'φ' => 'φ', # greek small letter phi, U+03C6 ISOgrk3 +'χ' => 'χ', # greek small letter chi, U+03C7 ISOgrk3 +'ψ' => 'ψ', # greek small letter psi, U+03C8 ISOgrk3 +'ω' => 'ω', # greek small letter omega, U+03C9 ISOgrk3 +'ϑ' => 'ϑ', # greek small letter theta symbol, U+03D1 NEW +'ϒ' => 'ϒ', # greek upsilon with hook symbol, U+03D2 NEW +'ϖ' => 'ϖ', # greek pi symbol, U+03D6 ISOgrk3 +'•' => '•', # bullet = black small circle, U+2022 ISOpub +'…' => '…', # horizontal ellipsis = three dot leader, U+2026 ISOpub +'′' => '′', # prime = minutes = feet, U+2032 ISOtech +'″' => '″', # double prime = seconds = inches, U+2033 ISOtech +'‾' => '‾', # overline = spacing overscore, U+203E NEW +'⁄' => '⁄', # fraction slash, U+2044 NEW +'℘' => '℘', # script capital P = power set = Weierstrass p, U+2118 ISOamso +'ℑ' => 'ℑ', # blackletter capital I = imaginary part, U+2111 ISOamso +'ℜ' => 'ℜ', # blackletter capital R = real part symbol, U+211C ISOamso +'™' => '™', # trade mark sign, U+2122 ISOnum +'ℵ' => 'ℵ', # alef symbol = first transfinite cardinal, U+2135 NEW +'←' => '←', # leftwards arrow, U+2190 ISOnum +'↑' => '↑', # upwards arrow, U+2191 ISOnum +'→' => '→', # rightwards arrow, U+2192 ISOnum +'↓' => '↓', # downwards arrow, U+2193 ISOnum +'↔' => '↔', # left right arrow, U+2194 ISOamsa +'↵' => '↵', # downwards arrow with corner leftwards = carriage return, U+21B5 NEW +'⇐' => '⇐', # leftwards double arrow, U+21D0 ISOtech +'⇑' => '⇑', # upwards double arrow, U+21D1 ISOamsa +'⇒' => '⇒', # rightwards double arrow, U+21D2 ISOtech +'⇓' => '⇓', # downwards double arrow, U+21D3 ISOamsa +'⇔' => '⇔', # left right double arrow, U+21D4 ISOamsa +'∀' => '∀', # for all, U+2200 ISOtech +'∂' => '∂', # partial differential, U+2202 ISOtech +'∃' => '∃', # there exists, U+2203 ISOtech +'∅' => '∅', # empty set = null set = diameter, U+2205 ISOamso +'∇' => '∇', # nabla = backward difference, U+2207 ISOtech +'∈' => '∈', # element of, U+2208 ISOtech +'∉' => '∉', # not an element of, U+2209 ISOtech +'∋' => '∋', # contains as member, U+220B ISOtech +'∏' => '∏', # n-ary product = product sign, U+220F ISOamsb +'∑' => '∑', # n-ary sumation, U+2211 ISOamsb +'−' => '−', # minus sign, U+2212 ISOtech +'∗' => '∗', # asterisk operator, U+2217 ISOtech +'√' => '√', # square root = radical sign, U+221A ISOtech +'∝' => '∝', # proportional to, U+221D ISOtech +'∞' => '∞', # infinity, U+221E ISOtech +'∠' => '∠', # angle, U+2220 ISOamso +'∧' => '∧', # logical and = wedge, U+2227 ISOtech +'∨' => '∨', # logical or = vee, U+2228 ISOtech +'∩' => '∩', # intersection = cap, U+2229 ISOtech +'∪' => '∪', # union = cup, U+222A ISOtech +'∫' => '∫', # integral, U+222B ISOtech +'∴' => '∴', # therefore, U+2234 ISOtech +'∼' => '∼', # tilde operator = varies with = similar to, U+223C ISOtech +'≅' => '≅', # approximately equal to, U+2245 ISOtech +'≈' => '≈', # almost equal to = asymptotic to, U+2248 ISOamsr +'≠' => '≠', # not equal to, U+2260 ISOtech +'≡' => '≡', # identical to, U+2261 ISOtech +'≤' => '≤', # less-than or equal to, U+2264 ISOtech +'≥' => '≥', # greater-than or equal to, U+2265 ISOtech +'⊂' => '⊂', # subset of, U+2282 ISOtech +'⊃' => '⊃', # superset of, U+2283 ISOtech +'⊄' => '⊄', # not a subset of, U+2284 ISOamsn +'⊆' => '⊆', # subset of or equal to, U+2286 ISOtech +'⊇' => '⊇', # superset of or equal to, U+2287 ISOtech +'⊕' => '⊕', # circled plus = direct sum, U+2295 ISOamsb +'⊗' => '⊗', # circled times = vector product, U+2297 ISOamsb +'⊥' => '⊥', # up tack = orthogonal to = perpendicular, U+22A5 ISOtech +'⋅' => '⋅', # dot operator, U+22C5 ISOamsb +'⌈' => '⌈', # left ceiling = apl upstile, U+2308 ISOamsc +'⌉' => '⌉', # right ceiling, U+2309 ISOamsc +'⌊' => '⌊', # left floor = apl downstile, U+230A ISOamsc +'⌋' => '⌋', # right floor, U+230B ISOamsc +'⟨' => '〈', # left-pointing angle bracket = bra, U+2329 ISOtech +'⟩' => '〉', # right-pointing angle bracket = ket, U+232A ISOtech +'◊' => '◊', # lozenge, U+25CA ISOpub +'♠' => '♠', # black spade suit, U+2660 ISOpub +'♣' => '♣', # black club suit = shamrock, U+2663 ISOpub +'♥' => '♥', # black heart suit = valentine, U+2665 ISOpub +'♦' => '♦', # black diamond suit, U+2666 ISOpub +'"' => '"', # quotation mark = APL quote, U+0022 ISOnum +'&' => '&', # ampersand, U+0026 ISOnum +'<' => '<', # less-than sign, U+003C ISOnum +'>' => '>', # greater-than sign, U+003E ISOnum +'Œ' => 'Œ', # latin capital ligature OE, U+0152 ISOlat2 +'œ' => 'œ', # latin small ligature oe, U+0153 ISOlat2 +'Š' => 'Š', # latin capital letter S with caron, U+0160 ISOlat2 +'š' => 'š', # latin small letter s with caron, U+0161 ISOlat2 +'Ÿ' => 'Ÿ', # latin capital letter Y with diaeresis, U+0178 ISOlat2 +'ˆ' => 'ˆ', # modifier letter circumflex accent, U+02C6 ISOpub +'˜' => '˜', # small tilde, U+02DC ISOdia +' ' => ' ', # en space, U+2002 ISOpub +' ' => ' ', # em space, U+2003 ISOpub +' ' => ' ', # thin space, U+2009 ISOpub +'‌' => '‌', # zero width non-joiner, U+200C NEW RFC 2070 +'‍' => '‍', # zero width joiner, U+200D NEW RFC 2070 +'‎' => '‎', # left-to-right mark, U+200E NEW RFC 2070 +'‏' => '‏', # right-to-left mark, U+200F NEW RFC 2070 +'–' => '–', # en dash, U+2013 ISOpub +'—' => '—', # em dash, U+2014 ISOpub +'‘' => '‘', # left single quotation mark, U+2018 ISOnum +'’' => '’', # right single quotation mark, U+2019 ISOnum +'‚' => '‚', # single low-9 quotation mark, U+201A NEW +'“' => '“', # left double quotation mark, U+201C ISOnum +'”' => '”', # right double quotation mark, U+201D ISOnum +'„' => '„', # double low-9 quotation mark, U+201E NEW +'†' => '†', # dagger, U+2020 ISOpub +'‡' => '‡', # double dagger, U+2021 ISOpub +'‰' => '‰', # per mille sign, U+2030 ISOtech +'‹' => '‹', # single left-pointing angle quotation mark, U+2039 ISO proposed +'›' => '›', # single right-pointing angle quotation mark, U+203A ISO proposed +'€' => '€', # euro sign, U+20AC NEW +''' => ''', # apostrophe = APL quote, U+0027 ISOnum +]; |