From 53a114527dbd491752cbbaab993a76f14269aefc Mon Sep 17 00:00:00 2001 From: HugoFara Date: Thu, 6 Aug 2026 15:58:12 +0200 Subject: [PATCH 1/9] feat(book): bundle an in-tree EPUB reader, drop kiwilan/php-ebook (#263) EPUB import is a core feature, so it should not pull an external Composer package to work. LWT now ships its own reader under src/Modules/Book/Infrastructure/Epub/, covering exactly what the importer consumed: - EpubArchive read-only ZIP accessor, case-insensitive entry lookup and path normalisation for hrefs like "text/../images/x.jpg" - EpubReader OCF container -> OPF package -> manifest + spine, plus both tables of contents (EPUB 2 NCX and EPUB 3 nav document) - EpubBook / EpubDocument / EpubChapter the models the service reads php-ebook also handled MOBI, CBZ and PDF, none of which LWT imports, so most of it was unused. Removing it dropped four transitive dependencies as well (kiwilan/php-archive, kiwilan/php-xml-reader, smalot/pdfparser, spatie/temporary-directory), taking composer.lock from 20 packages to 15. Parsing needs only ext-zip and ext-dom, both already required. XML is parsed with LIBXML_NONET and without LIBXML_NOENT, so entity substitution stays off and a hostile EPUB cannot mount an XXE attack. A test covers this and is non-vacuous: the same payload does expand under NOENT. EpubParserService keeps its public contract; only the types it names changed (isNavigationFile now takes an EpubDocument). resolveFormat() is gone because the reader detects EPUB from archive contents rather than a filename extension, which is what #232 worked around. The $originalName parameter stays and now sharpens error messages. New: 14 EpubReaderTest cases building real EPUBs on disk, covering NCX and nav TOCs, spine order, fragment hrefs, duplicate nav points, a missing TOC, a malformed container, a non-ZIP file and the XXE payload. This does not on its own remove the need for `composer install` from a source checkout: phpmailer and the two oauth2 libraries are still required. Refs #263 --- CHANGELOG.md | 18 + composer.json | 3 +- composer.lock | 502 +++--------------- docs-src/developer/v3-changes.md | 3 +- .../Services/EpubParserService.php | 205 +++---- .../Book/Infrastructure/Epub/EpubArchive.php | 145 +++++ .../Book/Infrastructure/Epub/EpubBook.php | 134 +++++ .../Book/Infrastructure/Epub/EpubChapter.php | 70 +++ .../Book/Infrastructure/Epub/EpubDocument.php | 84 +++ .../Book/Infrastructure/Epub/EpubReader.php | 487 +++++++++++++++++ .../Services/EpubParserServiceTest.php | 24 +- .../Infrastructure/Epub/EpubReaderTest.php | 428 +++++++++++++++ 12 files changed, 1529 insertions(+), 574 deletions(-) create mode 100644 src/Modules/Book/Infrastructure/Epub/EpubArchive.php create mode 100644 src/Modules/Book/Infrastructure/Epub/EpubBook.php create mode 100644 src/Modules/Book/Infrastructure/Epub/EpubChapter.php create mode 100644 src/Modules/Book/Infrastructure/Epub/EpubDocument.php create mode 100644 src/Modules/Book/Infrastructure/Epub/EpubReader.php create mode 100644 tests/backend/Modules/Book/Infrastructure/Epub/EpubReaderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ccd231d..869a644dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ ones are marked like "v1.0.0-fork". ## [Unreleased] +### Changed + +* **EPUB import no longer depends on an external Composer package** (#263): + LWT now ships its own EPUB reader + (`src/Modules/Book/Infrastructure/Epub/`), replacing `kiwilan/php-ebook`. + It reads the OCF container, the OPF package document, and both flavours of + table of contents — the EPUB 2 NCX and the EPUB 3 navigation document — which + is everything the importer consumed. Dropping it also removed four transitive + dependencies (`kiwilan/php-archive`, `kiwilan/php-xml-reader`, + `smalot/pdfparser`, `spatie/temporary-directory`), taking `composer.lock` from + 20 packages to 15. Parsing relies only on `ext-zip` and `ext-dom`, both + already required. XML is parsed with entity substitution off and network + access disabled, so a hostile EPUB cannot mount an XXE attack. + + Note that this does not by itself remove the need to run `composer install` + from a source checkout — `phpmailer/phpmailer`, `league/oauth2-google` and + `thenetworg/oauth2-azure` are still required. + ## [3.3.0-fork] - 2026-08-06 ### Added diff --git a/composer.json b/composer.json index 17bab8d03..902b540a8 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,7 @@ "ext-zip": "*", "phpmailer/phpmailer": "^7.0", "league/oauth2-google": "^5.0", - "thenetworg/oauth2-azure": "^2.2", - "kiwilan/php-ebook": "^3.0" + "thenetworg/oauth2-azure": "^2.2" }, "require-dev": { "vimeo/psalm": "^6.15", diff --git a/composer.lock b/composer.lock index 72c04c840..760927c4a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "91f8c99b578822d1f2b5c7e58c0524ae", + "content-hash": "558529a001303c8e9a887c50f67dffb4", "packages": [ { "name": "firebase/php-jwt", @@ -403,224 +403,6 @@ ], "time": "2026-07-16T22:23:49+00:00" }, - { - "name": "kiwilan/php-archive", - "version": "2.3.02", - "source": { - "type": "git", - "url": "https://github.com/kiwilan/php-archive.git", - "reference": "2e202603396a620720aad676c4037eebc0443090" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kiwilan/php-archive/zipball/2e202603396a620720aad676c4037eebc0443090", - "reference": "2e202603396a620720aad676c4037eebc0443090", - "shasum": "" - }, - "require": { - "php": "^8.1", - "smalot/pdfparser": "^2.4", - "spatie/temporary-directory": "^2.1" - }, - "require-dev": { - "laravel/pint": "^1.6", - "pestphp/pest": "^2.0", - "phpstan/phpstan": "^1.10", - "spatie/ray": "^1.28" - }, - "suggest": { - "ext-imagick": "*", - "ext-rar": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "Kiwilan\\Archive\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ewilan Rivière", - "email": "ewilan.riviere@gmail.com", - "role": "Developer" - } - ], - "description": "PHP package to handle archives (.zip, .rar, .tar, .7z, .pdf) with unified API and hybrid solution (native/p7zip), designed to works with EPUB and CBA (.cbz, .cbr, .cb7, .cbt).", - "homepage": "https://github.com/kiwilan/php-archive", - "keywords": [ - "7z", - "7zip", - "archive", - "cb7", - "cbr", - "cbt", - "cbz", - "ebook", - "epub", - "pdf", - "php", - "rar", - "tar", - "zip" - ], - "support": { - "issues": "https://github.com/kiwilan/php-archive/issues", - "source": "https://github.com/kiwilan/php-archive/tree/v2.3.02" - }, - "funding": [ - { - "url": "https://github.com/kiwilan", - "type": "github" - } - ], - "time": "2025-08-31T13:13:38+00:00" - }, - { - "name": "kiwilan/php-ebook", - "version": "3.0.09", - "source": { - "type": "git", - "url": "https://github.com/kiwilan/php-ebook.git", - "reference": "ddc5223f1ecc31a220369b3996a9202dbef875f4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kiwilan/php-ebook/zipball/ddc5223f1ecc31a220369b3996a9202dbef875f4", - "reference": "ddc5223f1ecc31a220369b3996a9202dbef875f4", - "shasum": "" - }, - "require": { - "kiwilan/php-archive": "^2.3.02", - "kiwilan/php-xml-reader": "^1.1.0", - "php": "^8.1" - }, - "require-dev": { - "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0", - "kiwilan/php-audio": "^4.0.01", - "laravel/pint": "^1.7", - "pestphp/pest": "^2.0", - "phpstan/phpstan": "^1.10", - "spatie/ray": "^1.28" - }, - "type": "library", - "autoload": { - "psr-4": { - "Kiwilan\\Ebook\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ewilan Rivière", - "email": "ewilan.riviere@gmail.com", - "role": "Developer" - } - ], - "description": "PHP package to read metadata and extract covers from eBooks, comics and audiobooks.", - "homepage": "https://github.com/kiwilan/php-ebook", - "keywords": [ - "CBA", - "audiobook", - "azw", - "azw3", - "book", - "calibre", - "cb7", - "cbam", - "cbr", - "cbt", - "cbz", - "comic", - "comicinfo", - "cover", - "ebook", - "epub", - "epub2", - "epub3", - "fb2", - "metadata", - "mobi", - "opf", - "pdf", - "php" - ], - "support": { - "issues": "https://github.com/kiwilan/php-ebook/issues", - "source": "https://github.com/kiwilan/php-ebook/tree/v3.0.09" - }, - "funding": [ - { - "url": "https://github.com/kiwilan", - "type": "github" - } - ], - "time": "2025-08-31T14:21:46+00:00" - }, - { - "name": "kiwilan/php-xml-reader", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/kiwilan/php-xml-reader.git", - "reference": "f7848a5e3dec0c4fd1814c9d5d71219867bd2273" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kiwilan/php-xml-reader/zipball/f7848a5e3dec0c4fd1814c9d5d71219867bd2273", - "reference": "f7848a5e3dec0c4fd1814c9d5d71219867bd2273", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "laravel/pint": "^1.2", - "pestphp/pest": "^1.20", - "phpstan/phpstan": "^1.10", - "spatie/ray": "^1.28" - }, - "type": "library", - "autoload": { - "psr-4": { - "Kiwilan\\XmlReader\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ewilan Rivière", - "email": "ewilan.riviere@gmail.com", - "role": "Developer" - } - ], - "description": "PHP package to read XML with nice API.", - "homepage": "https://github.com/kiwilan/php-xml-reader", - "keywords": [ - "php", - "xml" - ], - "support": { - "issues": "https://github.com/kiwilan/php-xml-reader/issues", - "source": "https://github.com/kiwilan/php-xml-reader/tree/v1.1.0" - }, - "funding": [ - { - "url": "https://github.com/kiwilan", - "type": "github" - } - ], - "time": "2024-10-03T17:34:14+00:00" - }, { "name": "league/oauth2-client", "version": "2.9.0", @@ -1027,118 +809,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "smalot/pdfparser", - "version": "v2.12.5", - "source": { - "type": "git", - "url": "https://github.com/smalot/pdfparser.git", - "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354", - "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "ext-zlib": "*", - "php": ">=7.1", - "symfony/polyfill-mbstring": "^1.18" - }, - "type": "library", - "autoload": { - "psr-0": { - "Smalot\\PdfParser\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0" - ], - "authors": [ - { - "name": "Sebastien MALOT", - "email": "sebastien@malot.fr" - } - ], - "description": "Pdf parser library. Can read and extract information from pdf file.", - "homepage": "https://www.pdfparser.org", - "keywords": [ - "extract", - "parse", - "parser", - "pdf", - "text" - ], - "support": { - "issues": "https://github.com/smalot/pdfparser/issues", - "source": "https://github.com/smalot/pdfparser/tree/v2.12.5" - }, - "time": "2026-04-17T11:37:58+00:00" - }, - { - "name": "spatie/temporary-directory", - "version": "2.4.0", - "source": { - "type": "git", - "url": "https://github.com/spatie/temporary-directory.git", - "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", - "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Spatie\\TemporaryDirectory\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alex Vanderbist", - "email": "alex@spatie.be", - "homepage": "https://spatie.be", - "role": "Developer" - } - ], - "description": "Easily create, use and destroy temporary directories", - "homepage": "https://github.com/spatie/temporary-directory", - "keywords": [ - "php", - "spatie", - "temporary-directory" - ], - "support": { - "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" - }, - "funding": [ - { - "url": "https://spatie.be/open-source/support-us", - "type": "custom" - }, - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2026-06-22T07:55:44+00:00" - }, { "name": "symfony/deprecation-contracts", "version": "v3.7.1", @@ -1210,91 +880,6 @@ ], "time": "2026-06-05T06:23:12+00:00" }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-27T06:59:30+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.37.0", @@ -6465,6 +6050,91 @@ ], "time": "2026-05-25T13:48:31+00:00" }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", diff --git a/docs-src/developer/v3-changes.md b/docs-src/developer/v3-changes.md index 6c7f7ae46..017d98097 100644 --- a/docs-src/developer/v3-changes.md +++ b/docs-src/developer/v3-changes.md @@ -1603,7 +1603,8 @@ Version 3 adds unified text import with EPUB file support and automatic splittin | Component | Location | Purpose | |-----------|----------|---------| -| `EpubParserService` | `src/Modules/Book/Application/Services/` | EPUB extraction using `kiwilan/php-ebook` | +| `EpubParserService` | `src/Modules/Book/Application/Services/` | EPUB extraction and HTML-to-text cleanup | +| `EpubReader` | `src/Modules/Book/Infrastructure/Epub/` | In-tree EPUB parser (OCF container, OPF, NCX / EPUB 3 nav) | | `TextSplitterService` | `src/Modules/Book/Application/Services/` | Split large texts at paragraph boundaries | | `Book` entity | `src/Modules/Book/Domain/Book.php` | Book domain model with progress tracking | | `ImportEpub` use case | `src/Modules/Book/Application/UseCases/` | Orchestrates EPUB import workflow | diff --git a/src/Modules/Book/Application/Services/EpubParserService.php b/src/Modules/Book/Application/Services/EpubParserService.php index 9d98a2ba9..f1dc3a44a 100644 --- a/src/Modules/Book/Application/Services/EpubParserService.php +++ b/src/Modules/Book/Application/Services/EpubParserService.php @@ -17,19 +17,20 @@ namespace Lwt\Modules\Book\Application\Services; -use Kiwilan\Ebook\Ebook; -use Kiwilan\Ebook\Formats\Epub\EpubModule; -use Kiwilan\Ebook\Formats\Epub\Parser\EpubChapter; -use Kiwilan\Ebook\Formats\Epub\Parser\EpubHtml; -use Kiwilan\Ebook\Models\BookAuthor; +use Lwt\Modules\Book\Infrastructure\Epub\EpubBook; +use Lwt\Modules\Book\Infrastructure\Epub\EpubChapter; +use Lwt\Modules\Book\Infrastructure\Epub\EpubDocument; +use Lwt\Modules\Book\Infrastructure\Epub\EpubReader; use InvalidArgumentException; use RuntimeException; /** * Service for parsing EPUB files and extracting content. * - * Uses the kiwilan/php-ebook library to read EPUB files and extract - * metadata and chapter content for import into LWT. + * Reads EPUB files through the in-tree {@see EpubReader} and extracts metadata + * and chapter content for import into LWT. LWT bundled its own reader in 3.4.0 + * so that EPUB import, a core feature, no longer depends on an external + * Composer package (#263). * * @since 3.0.0 */ @@ -110,22 +111,14 @@ public function parse(string $filePath, string $originalName = ''): array $this->assertZipWithinLimits($filePath); try { - $ebook = Ebook::read($filePath, $this->resolveFormat($filePath, $originalName)); - if ($ebook === null) { - throw new RuntimeException( - "Failed to read EPUB file: {$filePath}. " - . "The file may be corrupted or not a valid EPUB format." - ); - } + $ebook = EpubReader::read($filePath); } catch (\Throwable $e) { - // Provide more specific error messages - $message = $e->getMessage(); - if (str_contains($message, 'getManifest() on null')) { - $message = "EPUB file appears to be corrupted or has an invalid internal structure (missing manifest)."; - } elseif (str_contains($message, 'ZIP')) { - $message = "EPUB file could not be read as a ZIP archive. The file may be corrupted."; - } - throw new RuntimeException("Failed to parse EPUB file: {$message}", 0, $e); + $label = $originalName !== '' ? $originalName : $filePath; + throw new RuntimeException( + "Failed to parse EPUB file '{$label}': " . $e->getMessage(), + 0, + $e + ); } $metadata = [ @@ -147,64 +140,44 @@ public function parse(string $filePath, string $originalName = ''): array /** * Extract the primary author name from an ebook. * - * @param Ebook $ebook The ebook object + * @param EpubBook $ebook The parsed ebook * * @return string|null Author name or null if not found */ - private function extractAuthor(Ebook $ebook): ?string + private function extractAuthor(EpubBook $ebook): ?string { - $author = $ebook->getAuthorMain(); - if ($author !== null) { - return $author->getName(); - } - - /** @var BookAuthor[] $authors */ - $authors = $ebook->getAuthors(); - if (!empty($authors)) { - return $authors[0]->getName(); - } - - return null; + return $ebook->getAuthorMain(); } /** * Extract chapters from an ebook. * - * @param Ebook $ebook The ebook object + * @param EpubBook $ebook The parsed ebook * * @return array */ - private function extractChapters(Ebook $ebook): array + private function extractChapters(EpubBook $ebook): array { $chapters = []; $chapterNum = 1; - // Try to get chapters from the ebook via the EPUB parser - $epubModule = $this->getEpubModule($ebook); - if ($epubModule !== null) { - try { - /** @var EpubChapter[] $ebookChapters */ - $ebookChapters = $epubModule->getChapters(); - - foreach ($ebookChapters as $chapter) { - $content = $this->cleanHtmlContent($chapter->getContent()); - - // Skip empty chapters - if (trim($content) === '') { - continue; - } - - $chapters[] = [ - 'num' => $chapterNum, - 'title' => $chapter->getLabel() ?: "Chapter {$chapterNum}", - 'content' => $content, - ]; - $chapterNum++; - } - } catch (\Throwable $e) { - // If chapter extraction fails, log the error and continue with HTML fallback - error_log("EPUB chapter extraction failed, trying HTML fallback: " . $e->getMessage()); + /** @var EpubChapter[] $ebookChapters */ + $ebookChapters = $ebook->getChapters(); + + foreach ($ebookChapters as $chapter) { + $content = $this->cleanHtmlContent($chapter->getContent()); + + // Skip empty chapters + if (trim($content) === '') { + continue; } + + $chapters[] = [ + 'num' => $chapterNum, + 'title' => $chapter->getLabel() ?: "Chapter {$chapterNum}", + 'content' => $content, + ]; + $chapterNum++; } // If no chapters found, try to extract from HTML files @@ -215,64 +188,38 @@ private function extractChapters(Ebook $ebook): array return $chapters; } - /** - * Get the EpubModule from an Ebook. - * - * @param Ebook $ebook The ebook object - * - * @return EpubModule|null The EPUB module or null if not an EPUB - */ - private function getEpubModule(Ebook $ebook): ?EpubModule - { - $parser = $ebook->getParser(); - if ($parser === null) { - return null; - } - return $parser->getEpub(); - } - /** * Extract content from HTML files in the EPUB as fallback. * - * @param Ebook $ebook The ebook object + * @param EpubBook $ebook The parsed ebook * * @return array */ - private function extractFromHtmlFiles(Ebook $ebook): array + private function extractFromHtmlFiles(EpubBook $ebook): array { $chapters = []; $chapterNum = 1; - // Try to get HTML content via the EPUB module - $epubModule = $this->getEpubModule($ebook); - if ($epubModule !== null) { - try { - /** @var EpubHtml[] $htmlFiles */ - $htmlFiles = $epubModule->getHtml(); - foreach ($htmlFiles as $htmlFile) { - if ($this->isNavigationFile($htmlFile)) { - continue; - } - - $content = $this->cleanHtmlContent($htmlFile->getBody() ?? ''); - - if (trim($content) === '') { - continue; - } - - // Try to extract title from content - $title = $this->extractTitleFromContent($content, $chapterNum); - - $chapters[] = [ - 'num' => $chapterNum, - 'title' => $title, - 'content' => $content, - ]; - $chapterNum++; - } - } catch (\Throwable $e) { - error_log("EPUB HTML extraction fallback failed: " . $e->getMessage()); + foreach ($ebook->getHtml() as $htmlFile) { + if ($this->isNavigationFile($htmlFile)) { + continue; + } + + $content = $this->cleanHtmlContent($htmlFile->getBody()); + + if (trim($content) === '') { + continue; } + + // Try to extract title from content + $title = $this->extractTitleFromContent($content, $chapterNum); + + $chapters[] = [ + 'num' => $chapterNum, + 'title' => $title, + 'content' => $content, + ]; + $chapterNum++; } return $chapters; @@ -282,14 +229,14 @@ private function extractFromHtmlFiles(Ebook $ebook): array * Detect EPUB 3 navigation / TOC documents that should not appear as * chapters. * - * The kiwilan library's NCX-based getChapters() ignores nav.xhtml, but - * when an EPUB ships without an NCX the HTML fallback would otherwise - * include the nav document as a phantom chapter. Filename heuristics - * cover the common cases (nav.xhtml, toc.xhtml); the body sniff catches - * less conventionally-named EPUB 3 nav documents identified by the + * The TOC-driven getChapters() path ignores nav.xhtml, but when an EPUB + * ships without a usable table of contents the HTML fallback would + * otherwise include the nav document as a phantom chapter. Filename + * heuristics cover the common cases (nav.xhtml, toc.xhtml); the body sniff + * catches less conventionally-named EPUB 3 nav documents identified by the * `epub:type="toc"` (or related) attribute on a `