adrorocker/epub-builder

Convert Tiptap JSON to valid EPUB 3.3 files with an injectable renderer interface

Maintainers

Package info

github.com/adrorocker/epub-builder

pkg:composer/adrorocker/epub-builder

Statistics

Installs: 49

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-05-06 05:40 UTC

This package is auto-updated.

Last update: 2026-05-06 16:24:39 UTC


README

tests

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 zip and dom extensions.

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:

  1. The cover image, full-bleed
  2. The book title (large, centered)
  3. Authors ("by Robert Frost & Jane Doe")
  4. Contributors with role labels ("Translated by Constance Garnett")
  5. Publisher
  6. Year
  7. Rights / copyright line
  8. 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:

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.