fizk/epub

dev-master 2021-10-17 21:37 UTC

This package is auto-updated.

Last update: 2024-09-18 04:28:41 UTC


README

PHP库,用于将文本文件(HTML、Markdown等)转换为*.epu3文件。

理论。

将硬盘上的文件集(或它们所在的地方)转换为Epub的过程可以看作是三个步骤

  1. 遍历并收集组成Epub的文件。
  2. 格式化每个文件以符合Epub3标准。
  3. 合并文件并转换为*.epub3文件。

步骤 13 总是相同的。步骤 2 对每个用例都是特定的。这设置了流程和接口,所以你不需要担心获取和编译文件,你只需要关注提取和格式化。

示例

简单的Markdown示例

假设你有一个目录,其中包含一些你希望转换为Epub书籍的Markdown文档

projects
|-- index.php
|-- cover.jpg
`-- documents
    |-- chapter-1
    |   |   file1.md
    |   `-- file2.md
    |-- chapter-2
    |   |   file1.md
    |   `-- file2.md
    `-- chapter-3
    `-- file1.md

首先需要为这些Markdown文件实现一个Formatter。要创建一个格式化器,实现FormatterInterface

interface FormatterInterface
{
    public function setWorkspace(ContainerInterface $workspace);

    public function formatChapterTitle(ResourceInterface $resource): string;

    public function formatPageTitle(ResourceInterface $resource): string;

    public function chapterTemplate(ResourceInterface $resource, RecursiveIterator $children): ?DOMDocument;

    public function pageTemplate(ResourceInterface $resource): ?DOMDocument;
}

我们可以采取捷径,扩展BlankFormatter并只覆盖pageTemplate方法

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $resource): ?DOMDocument
    {
        $htmlString = $markdown->format($resource->getContent());
        $dom = new DOMDocument();
        $dom->loadXML($htmlString);
        return $dom;
    }
}

index.php文件中设置样板代码。

use Epub\Epub3;
use Epub\Storage\StorageZip;
use Epub\Resource\RecursiveDirectory;
use Epub\Document\Package;

// Setup Package. It is the thing that describes the meta-data of the book
//  like author, title and creation date.
$package = new Package(uniqid() , 'Title', new DateTime());

// Setup a recursive iterator that will traverse and find all the markdown files
$iterator = new RecursiveDirectory(realpath(__DIR__ .'/documents'));

// Setup storage. It will reseive all document and store them in a *.epub file
$storage = new StorageZip(__DIR__ .'/store.epub');

// Setup the Formatter, it will know hoe to change Markdown files in to XHTML files.
$formatter = new MarkdownFormatter();

// Run the Epub3 generator.
(new Epub3('Title'))
    ->setPackage($package)
    ->setCoverImage(file_get_contents(__DIR__ .'/cover.jpg'),'image/jpeg', 'jpg')
    ->setCoverPage('<!DOCTYPE html>
        <html xmlns="http://www.w3.org/1999/xhtml">
            <head>
                <meta charset="utf-8"/>
            </head>
            <body>
                <h1>Title page</h1>
            </body>
        </html>
    ')
    ->setStorage($storage)
    ->setFormatter($formatter)
    ->save($iterator);

运行此代码,你将在项目目录中得到一个Epub文件。

带有章节页面的示例

上面的示例没有为每个章节创建一个专门的页面。为此,我们需要实现chapterTemplate(ResourceInterface $resource, RecursiveIterator $children): ?DOMDocument方法。

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $resource): ?DOMDocument
    {
        // ... same as above
    }

    public function chapterTemplate(ResourceInterface $resource, RecursiveIterator $children): ?DOMDocument
    {
        $dom = new DOMDocument();
        $htmlElement = $dom->createElement('html');
        $bodyElement = $dom->createElement('body');
        $headerElement = $dom->createElement('h1', $resource->getName());

        $dom->appendChild($htmlElement);
        $htmlElement->appendChild($bodyElement);
        $bodyElement->appendChild($headerElement);

        return $dom;
    }
}

也许我们想更进一步,将章节中的所有页面列在章节标题页上。我们可以这样做

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $resource): ?DOMDocument
    {
        // ... same as above
    }

    public function chapterTemplate(ResourceInterface $resource, RecursiveIterator $children): ?DOMDocument
    {
        $dom = new DOMDocument();
        $htmlElement = $dom->createElement('html');
        $bodyElement = $dom->createElement('body');
        $headerElement = $dom->createElement('h1', $resource->getName());
        $listElement = $dom->createElement('ol');

        foreach($children as $child) {

            $listItemElement = $dom->createElement('li', $child->getName());
            $listElement->appendChild($listItemElement);
        }

        $dom->appendChild($htmlElement);
        $htmlElement->appendChild($bodyElement);
        $bodyElement->appendChild($headerElement);
        $bodyElement->appendChild($listElement);

        return $dom;
    }
}

如果这个章节页面需要其目录项可点击,我们需要使用通过$this->workspace属性可访问的encodeContentUri方法。

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $resource): ?DOMDocument
    {
        // ... same as above
    }

    public function chapterTemplate(ResourceInterface $resource, RecursiveIterator $children): ?DOMDocument
    {
        $dom = new DOMDocument();
        $htmlElement = $dom->createElement('html');
        $bodyElement = $dom->createElement('body');
        $headerElement = $dom->createElement('h1', $resource->getName());
        $listElement = $dom->createElement('ol');

        foreach($children as $child) {

            $linkElement = $dom->createElement('a', $child->getName());
            $linkElement->setAttribute('href', $this->workspace->encodeContentUri($child));

            $listItemElement = $dom->createElement('li');
            $listItemElement->appendChild($linkElement);
            $listElement->appendChild($listItemElement);
        }

        $dom->appendChild($htmlElement);
        $htmlElement->appendChild($bodyElement);
        $bodyElement->appendChild($headerElement);
        $bodyElement->appendChild($listElement);

        return $dom;
    }
}

自定义章节和页面名称。

到目前为止,我们一直在使用相同的文件和文件夹名称作为章节和页面的名称。这可能不是理想的选择,因为这些名称用于目录。让我们来修正它。

对于章节名称,我们只将文件夹名称中包含的数字使用。对于页面名称,我们将查看Markdown文档并提取第一个顶级标题元素。

class MarkdownFormatter extends BlankFormatter
{
    public function formatChapterTitle(ResourceInterface $resource): string
    {
        preg_match('/[0-9]+/', $resource->getName(), $match);
        return $match[0];
    }

    public function formatPageTitle(ResourceInterface $resource): string
    {
        $dom = new DOMDocument();
        $dom->loadHTML($markdown->parse($resource->getContent()));

        return $dom->getElementsByTagName('h1')->item->nodeValue;
    }
}

带有摘要的自定义章节页面

在这个例子中,我们将为每个章节提供自定义名称。我们还在每个章节页面上包括一个简短的摘要。为此,我们将在每个目录中包括一个chapter.md文件,其中包含章节名称和摘要。此chapter.md文件看起来像这样

# Name of a chaper

This is a descriptino of the chapter.

...并包括这个新文档看起来像这样

projects
|-- index.php
|-- cover.jpg
`-- documents
    |-- chapter-1
    |   |   chapter.md <---- name and description of chapter-1
    |   |   file1.md
    |   `-- file2.md
    |-- chapter-2
    |   |   chapter.md <---- name and description of chapter-2
    |   |   file1.md
    |   `-- file2.md
    `-- chapter-3
        |   chapter.md <---- name and description of chapter-3
        `-- file1.md

接下来,我们需要扩展RecursiveDirectory类,使其忽略chapter.md文件。

class ExtendedRecursiveDirectory extends RecursiveDirectory
{
    public function __construct($directory)
    {
        $this->currentFileObject = new SplFileInfo($directory);
        $this->children = array_values(array_map(function ($item) use ($directory) {
            return new class($directory . '/' . $item) extends SplFileInfo implements ResourceInterface
            {
                public function getContent()
                {
                    return \file_get_contents($this->getRealPath());
                }

                public function getName(): string
                {
                    return $this->getFilename();
                }

                public function getPath(): string
                {
                    return $this->getRealPath();
                }
            };
        }, array_diff(scandir($this->currentFileObject->getRealPath()), array('..', '.', '.DS_Store', 'chapter.md'))));
    }

    public function getChildren(): RecursiveIterator
    {
        return new ExtendedRecursiveDirectory($this->children[$this->index]->getRealPath());
    }
}

然后,在我们的Formatter中,当我们在章节/目录中时,访问markdown.md

class MarkdownFormatter extends BlankFormatter
{
    public function formatChapterTitle(ResourceInterface $chapter): string
    {
        $file = \file_get_contents($chapter->getPath() . '/markdown.md');

        $dom = new DOMDocument();
        $dom->loadHTML($file);
        $documentTitle = $dom->getElementsByTagName('h1')->item(0)->nodeValue;
        return mb_convert_encoding(trim(htmlspecialchars($documentTitle)), 'UTF-8');
    }

    public function chapterTemplate(ResourceInterface $chapter, RecursiveIterator $children): ?DOMDocument
    {
        $file = \file_get_contents($chapter->getPath() . '/markdown.md');

        $dom = new DOMDocument();
        $dom->loadHTML($file);

        $chapterDom = new DOMDocument();

        $htmlElement = $chapterDom->createElement('html');
        $bodyElement = $chapterDom->createElement('body');
        $headerElement = $chapterDom->createElement('h1', $dom->getElementsByTagName('h1')->item(0)->nodeValue);
        $bodyElement->appendChild($headerElement);

        foreach($dom->getElementsByTagName('p') as $element) {
            $paragraphElement = $dom->importNode($element, true);
            $bodyElement->appendChild($paragraphElement);
        }

        return $chapterDom;
    }
}

RecursiveDirectory

最后一个示例简要介绍了RecursiveDirectory类。它负责迭代遍历目录结构。它实际上实现了RecursiveIteratorIterator,因此可以实施不同的RecursiveIteratorIterator,例如遍历数据库、通过TCP/IP的外部服务,等等。

此存储库实际上还包含另一个迭代器:RecursiveMemory,它主要用于单元测试,但也实现了RecursiveIteratorIterator

StorageZip

上述所有示例都使用了StorageZip作为存储类。这个类负责将所有资源打包成一个Epub文件。这个类实现了StorageInterface接口。

interface StorageInterface
{
    public function createContainer(string $path): bool;

    public function createResource(string $path, string $content): bool;
}

这个仓库还提供了一个StorageFilesystem类,它将所有资源写回磁盘。这对于调试很有用。这个仓库还提供了一个StorageMemory,它主要用于单元测试。

虽然这段代码是为运行CLI程序编写的,但可以想象它也可以在Web服务器环境中使用。在这种情况下,StorageZip类需要重写,以便不将ZIP文件存储在磁盘上,而是将其放入输出缓冲区。

添加资源

许多Epub包含图像和其他需要嵌入到最终产品中的媒体。为此,我们提供了addResource($content, string $mimetype, string $extension): string;方法。

在这个例子中,我从一个文档中获取所有<img />标签,将它们转换为低分辨率的黑白JPEG图像,将其添加为资源,并返回一个资源URL,用于更新现有的<img />标签。

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $page): ?DOMDocument
    {
        $dom = new DOMDocument();
        $dom->loadHTML($page->getContent());

        $imagick = new Imagick();

        foreach($dom->getElementsByTagName('img') as $imageElement) {
            $imagick->readImageBlob(\file_get_contents($imageElement->getAttribute('src')));
            $imagick->setImageFormat('jpg');
            $imagick->setImageCompression(Imagick::COMPRESSION_JPEG);
            $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALEMATTE);
            $imagick->setImageCompressionQuality(35);

            $imagePath = $this->workspace->addResource($imagick->getImageBlob(), 'image/jpeg', 'jpg');
            $imageElement->setAttribute('src', $imagePath);

        }
        return $dom;
    }
}

XHTML命名空间。

因为Epub是由XHTML文档组成的集合,所以给<html>标签添加该命名空间是个好主意。

class MarkdownFormatter extends BlankFormatter
{
    public function pageTemplate(ResourceInterface $page): ?DOMDocument
    {
        $dom = new DOMDocument('1.0', 'utf-8');

        $html = $dom->createElement('html');
        $html->setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
        $html->setAttribute('xml:lang', 'en');
        $html->setAttribute('lang', 'en');

        // format and convert the document

        return $dom;
    }
}

我在示例中省略了这一点,以使代码更简洁,但我总是对所有文档这样做。

Epub元数据和包。

如果您想添加更多详细信息到Epub的元数据中,您可以这样做

$package = new Package(uniqid(), 'Title of epub', new DateTime());
$package->addMetadata(new MetadataDescription("Short description"));
$package->addMetadata(new MetadataAuthor('Autor name', 'Autor name, sorted by'));
$package->addMetadata(new MetadataPublisher('Publisher'));
$package->addMetadata(new MetadataPublishDate(new DateTime('2013-12-31')));
$package->addMetadata(new MetadataCover());

$epub = (new Epub3('Title of epub'))->setPackage($package);