adrorocker / epub-builder
Convert Tiptap JSON to valid EPUB 3.3 files with an injectable renderer interface
Requires
- php: ^8.1
- ext-dom: *
- ext-zip: *
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0
README
Convert Tiptap JSON documents into valid EPUB 3.3 files. Framework-agnostic, zero runtime dependencies, with an injectable renderer interface so you can plug in your own Tiptap-to-XHTML logic.
- Spec-conformant: passes the W3C reference validator (EPUBCheck 5.x) with 0 errors / 0 warnings.
- Cross-reader: tested against Apple Books, calibre, and the EPUBCheck reference. Ships an EPUB 2 NCX fallback for legacy readers (older Kindle apps, ADE pre-v4).
- Tiptap-native: a default renderer covers the standard Tiptap node and mark set. Custom Tiptap nodes can be registered with a one-line handler.
- No third-party runtime deps: only PHP 8.1+ and the
zipanddomextensions.
Installation
composer require adrorocker/epub-builder
Requirements: PHP 8.1+, ext-zip, ext-dom.
Quick start
<?php use AdroSoftware\EpubBuilder\EpubBuilder; $poemJson = json_decode(file_get_contents('poem.json'), true); $output = EpubBuilder::create() ->setMetadata(fn($m) => $m ->title('Selected Poems') ->author('Robert Frost') ->language('en') ->coverImage('/path/to/cover.jpg') ) ->addChapter('The Road Not Taken', $poemJson) ->addChapter('Stopping by Woods', $poemJson) ->build('/output/path/book.epub'); echo $output->getPath(); // /output/path/book.epub echo $output->getSize(); // 2,760,000 (bytes)
That's a complete, valid EPUB. Open in any reader.
Metadata
All metadata is set through a fluent builder. Title, language, and at least one chapter are required; everything else is optional.
$output = EpubBuilder::create() ->setMetadata(fn($m) => $m // Required ->title('White Nights and Other Stories') ->language('en') // Recommended ->author('Fyodor Dostoyevsky') ->author('Other Co-author') // call again for multiple authors ->publisher('epubBooks Classics') ->description('A collection of short stories.') ->rights('Public domain') ->coverImage('/path/to/cover.jpg') // Genre / category tags (Apple Books, Google Play, calibre use these) ->subject('Classic Fiction') ->subject('Short Stories') // Contributors with MARC relator role codes (see table below) ->contributor('Constance Garnett', 'trl') // translator ->contributor('Anne Editor', 'edt') // editor // Optional extras ->isbn('978-3-16-148410-0') ->series('My Collection', 1) ->direction('ltr') // 'ltr' (default) or 'rtl' ->identifier('urn:uuid:...') // defaults to a generated UUIDv4 ->modified(new \DateTimeImmutable()) // defaults to now (UTC) ) ->addChapter('Chapter One', $tiptapJson) ->build();
MARC relator codes
contributor() takes an optional second argument — a MARC relator code describing the contributor's role. Common ones:
| Code | Role |
|---|---|
trl |
Translator |
edt |
Editor |
ill |
Illustrator |
pht |
Photographer |
aui |
Introduction |
aft |
Afterword |
fwd |
Foreword |
cmp |
Compiled by |
nrt |
Narrated by |
oth |
Other (default) |
These codes are emitted into the OPF and translated to readable labels ("Translated by", "Edited by", …) on the auto-generated cover page.
Chapters
Each call to addChapter(string $title, array $tiptapJson) adds one chapter:
- The title becomes the chapter's
<h1>and a top-level entry in the table of contents. - The Tiptap JSON is rendered to XHTML using the default renderer (or a custom one — see below).
- Inner Tiptap headings nest under the chapter title in the TOC.
- The chapter is auto-registered as a page in the EPUB 3 page-list nav (so readers can show "page N of M"). Disable with
->disableAutoPageList(). - The chapter body is wrapped in
<section epub:type="chapter">for accessibility.
$builder ->addChapter('Introduction', $intro) ->addChapter('Chapter One', $ch1) ->addChapter('Chapter Two', $ch2);
Cover page
When you set a cover image, the library auto-generates a title page (OEBPS/chapters/cover.xhtml) showing:
- The cover image, full-bleed
- The book title (large, centered)
- Authors ("by Robert Frost & Jane Doe")
- Contributors with role labels ("Translated by Constance Garnett")
- Publisher
- Year
- Rights / copyright line
- Description (justified, smaller text)
This is rendered XHTML, so it shows up the same in every reader — even readers that don't surface OPF metadata in their library/info UIs.
->setMetadata(fn($m) => $m ->title('Book') ->coverImage('/path/to/cover.jpg') // jpg, png, gif, webp, svg );
The cover is automatically:
- Added to the OPF manifest with
properties="cover-image"(EPUB 3) and<meta name="cover">(EPUB 2 legacy). - Placed first in the spine with
linear="no"so it appears as cover artwork only, not as the first reading-flow page. - Added to the EPUB 3 landmarks nav and the legacy
<guide>block.
Output
build() returns an EpubOutput value object:
$output = $builder->build('/optional/explicit/path.epub'); $output->getPath(); // string — path on disk $output->getSize(); // int — byte size $output->getContents(); // string — raw .epub bytes $output->delete(); // remove the file // HTTP response — Apache/nginx/PHP-FPM, framework-agnostic $output->download('book.epub'); // sets headers, streams, exit() // Chunked streaming — write into any resource $output->streamTo(STDOUT); // returns int bytes written foreach ($output->chunks(8192) as $chunk) { // generator, no buffer // ... }
If you don't pass a path to build(), it writes to sys_get_temp_dir() with a random name.
Streaming without keeping a file
When you don't want a file at all — e.g. you're piping the EPUB straight into an HTTP response or an S3 upload — use buildToStream() or buildToString(). They use a temp file internally and delete it once the bytes are out.
// Write straight to the HTTP response body. Temp file is removed. header('Content-Type: application/epub+zip'); header('Content-Disposition: attachment; filename="book.epub"'); $out = fopen('php://output', 'wb'); EpubBuilder::create() ->setMetadata(fn($m) => $m->title('Book')->author('Author')) ->addChapter('Chapter 1', $tiptapJson) ->buildToStream($out); // Or load the bytes (small books, in-memory pipelines). $bytes = $builder->buildToString();
Laravel
use AdroSoftware\EpubBuilder\EpubBuilder; use Illuminate\Http\Response; public function downloadEpub(EpubBuilder $builder): Response { $output = $builder ->setMetadata(fn($m) => $m->title('Book')->author('Author')) ->addChapter('Chapter 1', $tiptapJson) ->build(storage_path('app/temp/book.epub')); return response()->download($output->getPath(), 'book.epub', [ 'Content-Type' => 'application/epub+zip', ]); }
Or stream without staging the file in your storage directory:
return response()->stream( function () use ($builder) { $builder->buildToStream(fopen('php://output', 'wb')); }, 200, [ 'Content-Type' => 'application/epub+zip', 'Content-Disposition' => 'attachment; filename="book.epub"', ], );
Symfony
use Symfony\Component\HttpFoundation\BinaryFileResponse; $output = EpubBuilder::create() ->setMetadata(fn($m) => $m->title('Book')->author('Author')) ->addChapter('Chapter 1', $tiptapJson) ->build(); return new BinaryFileResponse( $output->getPath(), headers: ['Content-Type' => 'application/epub+zip'], );
Streaming variant via StreamedResponse:
use Symfony\Component\HttpFoundation\StreamedResponse; return new StreamedResponse( function () use ($builder) { $builder->buildToStream(fopen('php://output', 'wb')); }, 200, [ 'Content-Type' => 'application/epub+zip', 'Content-Disposition' => 'attachment; filename="book.epub"', ], );
Custom Tiptap nodes
The default renderer ships handlers for the standard Tiptap node and mark set:
Nodes: doc, paragraph, heading, blockquote, codeBlock, bulletList, orderedList, listItem, horizontalRule, hardBreak, image, table, tableRow, tableCell, tableHeader, text, pageBreak
Marks: bold, italic, underline, strike, code, link, highlight, subscript, superscript
Unknown nodes are silently dropped (so editors emitting custom node types like unsplashImage, callout, embed don't crash builds). To opt in to rendering a custom node, write a NodeHandlerInterface and register it:
use AdroSoftware\EpubBuilder\Contracts\NodeHandlerInterface; use AdroSoftware\EpubBuilder\Renderers\DefaultTiptapRenderer; final class CalloutHandler implements NodeHandlerInterface { public function render(array $node, callable $renderChildren): string { $type = htmlspecialchars($node['attrs']['type'] ?? 'info', ENT_XML1 | ENT_QUOTES, 'UTF-8'); $content = $renderChildren($node['content'] ?? []); return "<aside class=\"callout callout--{$type}\">{$content}</aside>\n"; } } $builder = EpubBuilder::create(); $renderer = $builder->getRenderer(); if ($renderer instanceof DefaultTiptapRenderer) { $renderer->register('callout', new CalloutHandler()); }
Now {"type": "callout", "attrs": {"type": "warning"}, "content": [...]} renders to <aside class="callout callout--warning">…</aside>.
Custom node that needs to embed images
If your custom node has remote or local image references, register them with the AssetManager so they're embedded in the EPUB:
use AdroSoftware\EpubBuilder\Contracts\NodeHandlerInterface; use AdroSoftware\EpubBuilder\Epub\AssetManager; final class UnsplashImageHandler implements NodeHandlerInterface { public function __construct(private readonly AssetManager $assetManager) {} public function render(array $node, callable $renderChildren): string { $src = (string) ($node['attrs']['src'] ?? ''); if ($src === '') return ''; // AssetManager downloads remote URLs into OEBPS/images/ at zip-write time. // The 'jpg' hint is for URLs without a file extension in the path. $epubSrc = $this->assetManager->addImage($src, 'jpg'); $alt = htmlspecialchars($node['attrs']['alt'] ?? '', ENT_XML1 | ENT_QUOTES, 'UTF-8'); return "<figure><img src=\"{$epubSrc}\" alt=\"{$alt}\"/></figure>\n"; } } $builder->getRenderer()->register('unsplashImage', new UnsplashImageHandler( $builder->assetManager(), ));
A complete working example is in examples/UnsplashImageHandler.php.
Overriding a built-in node
override() replaces a default handler:
$renderer->override('image', new MyCdnImageHandler($builder->assetManager()));
Custom marks
Same pattern via MarkHandlerInterface:
use AdroSoftware\EpubBuilder\Contracts\MarkHandlerInterface; final class SmallCapsMark implements MarkHandlerInterface { public function render(string $content, array $mark): string { return "<span class=\"smallcaps\">{$content}</span>"; } } $renderer->registerMark('smallCaps', new SmallCapsMark());
Custom renderer (full replacement)
If you'd rather not use the default renderer at all — for example, you have an existing Tiptap-to-HTML pipeline you want to wrap — implement ContentRendererInterface:
use AdroSoftware\EpubBuilder\Contracts\ContentRendererInterface; final class MyRenderer implements ContentRendererInterface { public function render(array $tiptapJson): string { // Your own rendering logic. Return a well-formed XHTML body fragment // (no <html>, <body> wrapper — the library wraps it). return $myExistingTiptapEngine->toHtml($tiptapJson); } } EpubBuilder::create() ->setRenderer(new MyRenderer()) ->setMetadata(fn($m) => $m->title('Book')->author('A')) ->addChapter('One', $json) ->build();
You'd typically use this to wrap a third-party Tiptap PHP renderer like ueberdosis/tiptap-php. The default renderer remains an option you can fall back to.
Styling
A clean default stylesheet is embedded automatically. To customize:
// Replace the default with your own CSS file $builder->setStylesheet('/path/to/my-styles.css'); // Or append additional rules on top of the default $builder->addCss(' body { font-family: "Iowan Old Style", Georgia, serif; } blockquote { font-style: italic; } ');
Navigation features
The library auto-generates three EPUB nav structures (you don't have to think about any of them):
- TOC (
<nav epub:type="toc">) — every chapter at the top level, inner Tiptap headings nested below. - Landmarks (
<nav epub:type="landmarks">) — cover, table of contents, beginning of body. - Page-list (
<nav epub:type="page-list">) — one entry per chapter so readers can show "page N of M". Disable with->disableAutoPageList().
Plus the legacy formats for older readers:
- NCX (
OEBPS/toc.ncx) — EPUB 2 navigation. Older Kindle apps, Adobe Digital Editions pre-v4, and calibre's older TOC UI fall back to this. Disable with->disableNcx(). - Guide (
<guide>in OPF) — EPUB 2 reference list (cover, toc, beginning).
Verification
The library passes EPUBCheck 5.x (the W3C reference validator) with 0 errors / 0 warnings on every build. To verify your output:
brew install epubcheck # macOS
epubcheck path/to/your.epub
Expected output:
Validating using EPUB version 3.3 rules.
No errors or warnings detected.
Messages: 0 fatals / 0 errors / 0 warnings / 0 infos
EPUBCheck completed
Spec references
The library follows the W3C EPUB 3.3 Recommendation:
- EPUB 3.3 (umbrella)
- EPUB Packages 3.3 (OPF)
- EPUB Content Documents 3.3 (XHTML/SVG/MathML)
- EPUB OCF 3.3 (ZIP container)
- EPUB Reading Systems 3.3
Plus selected EPUB 2 features for legacy-reader compatibility (NCX, <guide>, <meta name="cover">).
API reference
EpubBuilder
| Method | Description |
|---|---|
EpubBuilder::create(): static |
Entry point — start a new builder. |
setMetadata(callable $cb): static |
$cb(BookMetadata $m) — configure metadata. |
addChapter(string $title, array $tiptapJson): static |
Add a chapter. |
setRenderer(ContentRendererInterface): static |
Replace the default Tiptap renderer entirely. |
getRenderer(): ContentRendererInterface |
Get the current renderer (used to register custom nodes/marks before adding chapters). |
setStylesheet(string $path): static |
Use a custom CSS file in place of the default. |
addCss(string $css): static |
Append CSS rules to the default (or custom) stylesheet. |
disableAutoPageList(): static |
Opt out of one-page-per-chapter page-list entries. |
disableNcx(): static |
Opt out of the legacy EPUB 2 NCX fallback. |
assetManager(): AssetManager |
Internal asset manager (for custom node handlers that embed images/fonts). |
tocBuilder(): TocBuilder |
Internal TOC builder (for custom heading-like nodes). |
pageListBuilder(): PageListBuilder |
Internal page-list builder (for custom page-break nodes). |
metadata(): BookMetadata |
Direct access to the metadata object. |
build(?string $path = null): EpubOutput |
Validate, render, and write the EPUB. |
buildToStream($resource, int $chunkSize = 8192): int |
Build and copy bytes into a writable stream resource; deletes the temp file afterwards. Returns bytes written. |
buildToString(): string |
Build and return the EPUB as a binary string; deletes the temp file afterwards. |
BookMetadata
All setters return static for chaining.
| Method | EPUB field |
|---|---|
title(string) |
<dc:title> (required) |
language(string) |
<dc:language> (required, BCP 47) |
author(string) |
<dc:creator> — call multiple times for multiple authors |
contributor(string $name, string $role = 'oth') |
<dc:contributor> with MARC relator role |
subject(string) |
<dc:subject> — call multiple times for multiple tags |
publisher(string) |
<dc:publisher> |
description(string) |
<dc:description> |
rights(string) |
<dc:rights> |
isbn(string) |
<dc:identifier> with urn:isbn: prefix |
coverImage(string $path) |
Triggers cover-page generation; image is embedded |
direction(string) |
'ltr' (default) or 'rtl' |
series(string $name, ?int $position) |
EPUB 3 collection metadata |
identifier(string) |
Override the auto-generated UUIDv4 |
modified(\DateTimeImmutable) |
Override the auto-generated modification time |
EpubOutput
| Method | Description |
|---|---|
getPath(): string |
Path on disk where the EPUB was written. |
getSize(): int |
File size in bytes. |
getContents(): string |
Raw EPUB bytes. |
download(string $filename = 'book.epub'): never |
Stream as HTTP download (calls exit). |
streamTo($resource, int $chunkSize = 8192): int |
Copy the EPUB into any writable stream resource in chunks. Returns bytes written. |
chunks(int $chunkSize = 8192): \Generator |
Yield the EPUB body as a generator of byte chunks. |
delete(): void |
Remove the file. |
Architecture overview
EpubBuilder ← public facade
├── BookMetadata
├── ContentRenderer ← DefaultTiptapRenderer (or your own)
│ ├── NodeRegistry
│ │ └── 17 default node handlers + your custom ones
│ └── MarkRegistry
│ └── 9 default mark handlers + your custom ones
├── TocBuilder ← collects TOC entries during render
├── PageListBuilder ← collects page entries during render
├── AssetManager ← collects image/font references during render
├── StyleManager ← default + custom CSS
├── PackageBuilder ← OPF (manifest, spine, metadata, guide)
├── NcxBuilder ← legacy EPUB 2 NCX
├── CoverPageBuilder ← title-page-style cover XHTML
├── LandmarksNav, PageListNav, XhtmlWrapper
├── ConformanceValidator
└── OcfWriter ← assembles the OCF ZIP (mimetype-first)
└── EpubOutput
The package is intentionally small and composable: replace any layer by passing a different implementation, or extend behavior by registering handlers in the registries.
License
MIT.