EPUB is a zipped collection of XHTML chapters with metadata. Going to PDF means turning reflowable content into fixed-page layout — which has surprises around fonts, page sizes, and chapter breaks. Calibre is the reference implementation; everything else is approximate. Use Calibre directly or via the API.

Method 1: Calibre ebook-convert via os/exec

Calibre's ebook-convert CLI is the de-facto standard for ebook conversion. It handles EPUB-to-PDF with proper TOC, page sizing, and font embedding.

# macOS
brew install --cask calibre

# Debian/Ubuntu
apt install calibre  # large install, ~500MB with deps
package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "time"
)

func epubToPDF(ctx context.Context, inPath, outPath string) error {
    cmd := exec.CommandContext(ctx,
        "ebook-convert", inPath, outPath,
        "--paper-size", "letter",
        "--pdf-default-font-size", "12",
        "--pdf-page-margin-top", "36",
        "--pdf-page-margin-bottom", "36",
        "--pdf-page-margin-left", "36",
        "--pdf-page-margin-right", "36",
        "--preserve-cover-aspect-ratio",
    )

    out, err := cmd.CombinedOutput()
    if err != nil {
        return fmt.Errorf("ebook-convert: %w (%s)", err, out)
    }
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
    defer cancel()

    if err := epubToPDF(ctx, "book.epub", "book.pdf"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("converted")
}

Useful flags:

  • --paper-size — letter, a4, a5, b5, kindle, etc. Default is letter.
  • --pdf-default-font-size — base font size in pt. Default is 12; bump to 14 for large-print output.
  • --pdf-page-margin-* — margins in pt. Default 72pt (1 inch) all sides; 36pt looks more modern.
  • --no-default-epub-cover — useful when your EPUB has its own cover and you don't want Calibre to add one.

Method 2: Extract EPUB + wkhtmltopdf

EPUB is a zip of XHTML files. You can extract it, concatenate the chapters, and render to PDF with wkhtmltopdf. This loses Calibre's TOC and metadata handling but works without the Calibre install.

apt install wkhtmltopdf
package main

import (
    "archive/zip"
    "context"
    "fmt"
    "io"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
)

func extractEPUB(epubPath, dstDir string) ([]string, error) {
    r, err := zip.OpenReader(epubPath)
    if err != nil {
        return nil, err
    }
    defer r.Close()

    var chapters []string
    for _, f := range r.File {
        outPath := filepath.Join(dstDir, f.Name)
        if f.FileInfo().IsDir() {
            os.MkdirAll(outPath, 0o755)
            continue
        }
        os.MkdirAll(filepath.Dir(outPath), 0o755)
        in, err := f.Open()
        if err != nil {
            return nil, err
        }
        out, err := os.Create(outPath)
        if err != nil {
            in.Close()
            return nil, err
        }
        io.Copy(out, in)
        in.Close()
        out.Close()

        if strings.HasSuffix(f.Name, ".xhtml") || strings.HasSuffix(f.Name, ".html") {
            chapters = append(chapters, outPath)
        }
    }
    return chapters, nil
}

func main() {
    tmp, _ := os.MkdirTemp("", "epub-")
    defer os.RemoveAll(tmp)

    chapters, err := extractEPUB("book.epub", tmp)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    args := append(chapters, "book.pdf")
    cmd := exec.CommandContext(context.Background(), "wkhtmltopdf", args...)
    if err := cmd.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

This produces a serviceable PDF but order depends on the chapter filenames being lexicographically sorted (often not the case). For correct chapter order, parse OEBPS/content.opf for the spine. Calibre handles all this automatically — for one-off conversions it's usually not worth the effort.

Method 3: ChangeThisFile API (Calibre fidelity, no install)

If you don't want Calibre on your hosts (~500MB with Qt deps), the API runs it server-side. Free tier covers 1,000 conversions/month.

package main

import (
    "bytes"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "os"
    "time"
)

const apiKey = "ctf_sk_your_key_here"

func epubToPDF(inPath, outPath string) error {
    body := &bytes.Buffer{}
    w := multipart.NewWriter(body)

    f, err := os.Open(inPath)
    if err != nil {
        return err
    }
    defer f.Close()

    fw, err := w.CreateFormFile("file", "input.epub")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "epub")
    _ = w.WriteField("target", "pdf")
    _ = w.Close()

    req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", w.FormDataContentType())

    client := &http.Client{Timeout: 180 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        msg, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
    }

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()
    _, err = io.Copy(out, resp.Body)
    return err
}

func main() {
    if err := epubToPDF("book.epub", "book.pdf"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The API uses Calibre with sane defaults (letter size, 12pt, modern margins). Output is a fully-formed PDF with TOC, embedded fonts, and chapter logic intact.

When to use each

ApproachBest forTradeoff
Calibre via os/execSelf-hosted batch jobs, full control over flagsLarge install, slow first run, single-threaded
Extract + wkhtmltopdfSingle-binary deploys, no Calibre dependencyLoses TOC, chapter ordering requires opf parsing
ChangeThisFile APISkip the install, no infra ownershipNetwork call, file size limit (25MB free)

CLI alternative: Pandoc + LaTeX

For typeset-quality output (academic books, literary fiction), Pandoc reads EPUB and renders via XeLaTeX:

apt install pandoc texlive-xetex
pandoc book.epub -o book.pdf --pdf-engine=xelatex --toc
cmd := exec.CommandContext(ctx, "pandoc", "book.epub", "-o", "book.pdf", "--pdf-engine=xelatex", "--toc")
out, err := cmd.CombinedOutput()

Pandoc + XeLaTeX produces beautiful typography but loses fidelity to the original EPUB's CSS-driven design. Use it for novels, not for technical books with code blocks and complex formatting.

Production tips

  • Set a 180s timeout, not 60s. Long books with images take 30-60s to convert; complex EPUBs with embedded fonts can take 2-3 minutes.
  • Pin Calibre version in containers. Calibre's PDF renderer changed default fonts in version 6.x. If you upgrade between deploys, page counts may shift.
  • Generate a TOC explicitly. Pass --toc to Calibre to ensure a navigable PDF outline. Without it, the PDF has chapters but no clickable sidebar TOC.
  • For Kindle output, use AZW3 not PDF. If your end goal is Kindle distribution, convert EPUB-to-AZW3 instead — AZW3 reflows on small screens, PDF doesn't.

If you can install Calibre, that's the most-correct option. If you can't, use the API for the same Calibre-quality output without the install. Free tier covers 1,000 conversions/month.