I have multiple sets of JPEG photos (not images/scans of books, but of e.g. my children), that I would like to turn into EPUB files, so I can take them along on my ebook-reader and view them.

Are there any command-line programs out there that allow me to automate this process?

  • Do you want it to be converted to text(OCR) or do you just want to have a book with one picture on each page? Commented Dec 22, 2013 at 13:37
  • @LarsPötter I updated my question, I actually meant 'real' photos of non-text material, to view as a slideshow. Thanks for pointing out this was unclear.
    – Anthon
    Commented Dec 22, 2013 at 13:42

3 Answers 3


Part of the program I wrote for scanning and EPUB-ing scanned images can be reused for this purpose. I tried to make a minimal version, the output was tested with calibre book reader and a BookEen reading device and passed EpubCheck version 3.0.1 without errors.

The program requires you to provide a Title (--title) and one or more JPEG files, which are taken as is (not scaled or rotated). Some extra options allow for specifying an output file name (otherwise constructed from the title), author/creator and series/series-index (as used by Calibre):

jpeg2epub --title "My Photo Album" img*.jpg

will create a file My_Photo_Album.epub in the current directory.

The program works with Python 2.7 and standard libraries, but will not work without modification on Python 3.X. All processing is done in memory, the computers memory limits the size of your EPUB, but no intermediate files are written.

#!/usr/bin/env python
# coding: utf-8
# jpeg2epub: copyright (C) 2013, RUAMEL bvba, A. van der Neut

import os
import sys
from io import open
from textwrap import dedent
from cStringIO import StringIO
import zipfile
import uuid
import datetime

class Jpeg2Epub(object):
    """simple epub creator for series of JPEG image files

    creates the file epub file in memory
    version = 1  # class version when used a s library

    def __init__(self, title, file_name=None, creator=None, title_sort=None,
                 series=None, series_idx=None, verbose=0):
        self._output_name = file_name if file_name else \
            title.replace(' ', '_') + '.epub'
        self._files = None
        self._zip = None  # the in memory zip file
        self._zip_data = None
        self._content = []
        self._count = 0
        self._series = series
        self._series_idx = series_idx
        self.d = dict(
            title_sort=title_sort if title_sort else title,
            creator=creator if creator else 'Unknown',
            mt='application/oebps-package+xml',  # media-type

    def __enter__(self):
        return self

    def __exit__(self, typ, value, traceback):
        if value is None:
            if isinstance(self._zip_data, basestring):
            self._zip = None
            self.d['nav_point'] = None
            with open(self._output_name, 'wb') as ofp:
            # minimal test: listing contents of EPUB
            # os.system('unzip -lv ' + self._output_name)
            return True
        return False

    def add_image_file(self, file_name):
        self._count += 1

    def _write_toc(self):
        self._add_from_bytes(self.d['toc_name'], dedent("""\
        <?xml version='1.0' encoding='utf-8'?>
        <ncx xmlns="{ncx_ns}" version="2005-1" xml:lang="eng">
            <meta content="{uuid}" name="dtb:uid"/>
            <meta content="2" name="dtb:depth"/>
            <meta content="ruamel.jpeg2epub (0.1)" name="dtb:generator"/>
            <meta content="0" name="dtb:totalPageCount"/>
            <meta content="0" name="dtb:maxPageNumber"/>
            <navPoint id="{nav_uuid}" playOrder="1">
              <content src="{nav_point}"/>
        self._content.append((self.d['toc_name'], 'ncx',

    def _write_content(self):
        d = self.d.copy()
        manifest = []
        spine = []
        d['manifest'] = ''
        d['spine'] = ''
        for f in self._content:
                '<item href="{}" id="{}" media-type="{}"/>'.format(*f))
            if f[1].startswith('html'):
                spine.append('<itemref idref="{}"/>'.format(f[1]))
        d['manifest'] = '\n    '.join(manifest)
        d['spine'] = '\n    '.join(spine)
        d['ts'] = datetime.datetime.utcnow().isoformat() + '+00:00'
        d['series'] = ''
        if self._series:
            d['series'] = \
                u'\n' \
                '<meta name="calibre:series" content="{}"/>' \
                '<meta name="calibre:series_index" content="{}"/>'.format(
                    self._series, self._series_idx)
        self._add_from_bytes(self.d["opf_name"], dedent(u"""\
        <?xml version='1.0' encoding='utf-8'?>
        <package xmlns="{opf_ns}" unique-identifier="uuid_id" version="2.0">
          <metadata xmlns:xsi="{xsi_ns}" xmlns:opf="{opf_ns}"
            <meta name="calibre:timestamp" content="{ts}"/>
            <meta name="calibre:title_sort" content="{title_sort}"/>
            <meta name="cover" content="cover"/>
            <dc:identifier id="uuid_id" opf:scheme="uuid">{uuid}
            <dc:identifier opf:scheme="calibre">{uuid}</dc:identifier>
          <spine toc="ncx">

    def _add_html(self, title):
        file_name = self._name(False)
        d = self.d.copy()
        d['title'] = title
        d['img_name'] = self._name()
        self._add_from_bytes(file_name, dedent(u"""\
        <?xml version='1.0' encoding='utf-8'?>
        <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
            <meta http-equiv="Content-Type" content="text/html; \
          <link href="{style_sheet}" rel="stylesheet" type="text/css"/>
          <body class="album">
              <img src="{img_name}" class="albumimg" alt="{title}"/>
        self._content.append((file_name, 'html{}'.format(self._count),
        if self.d['nav_point'] is None:
            self.d['nav_point'] = file_name

    def _write_style_sheet(self):
        file_name = self.d['style_sheet']
        self._add_from_bytes(file_name, dedent("""\
        .album {
            display: block;
            font-size: 1em;
            padding: 0;
            margin: 0;
        .albumimg {
            height: auto;
            max-height: 100%;
            max-width: 100%;
            width: auto
        self._content.append((file_name, 'css', 'text/css'))

    def _name(self, image=True):
        """no leading zero's necessary in zip internal filenames"""
        return '{}.{}'.format(self._count, 'jpg' if image else 'xhtml')

    def _add_image_file(self, file_name, width=None, height=None,
                        strip=None, max_strip_pixel=None, z=None):
        z = z if z else self.zip  # initializes if not done yet
        # you can compress JPEGs, but with little result (1-8%) and
        # more complex/slow decompression (zip then jpeg)
        # Gain 2.836 Mb -> 2.798 Mb ( ~ 1% difference )
        if width:
            im = EpubImage(file_name)
            z.writestr(self._name(), im.read(), zipfile.ZIP_STORED)
            z.write(file_name, self._name())
        self._content.append((self._name(), 'img{}'.format(self._count),

    def zip(self):
        if self._zip is not None:
            return self._zip
        self._zip_data = StringIO()
        # create zip with default compression
        #self._zip_data = '/var/tmp/epubtmp/yy.zip'
        self._zip = zipfile.ZipFile(self._zip_data, "a",
                                    zipfile.ZIP_DEFLATED, False)
        self.d['uuid'] = uuid.uuid4()
        self.d['nav_uuid'] = uuid.uuid4()
        return self._zip

    def _add_from_bytes(self, file_name, data, no_compression=False):
            file_name, data,
            compress_type=zipfile.ZIP_STORED if no_compression else None)

    def _add_mimetype(self):
        self._add_from_bytes('mimetype', dedent("""\
        """).rstrip(), no_compression=True)

    def _add_container(self):
        self._add_from_bytes('META-INF/container.xml', dedent("""\
        <?xml version="1.0"?>
           <container version="1.0" xmlns="{cont_urn}">
            <rootfile full-path="{opf_name}" media-type="{mt}"/>

def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--title", "-t", required=True)
    parser.add_argument("--title-sort", help="alternative title for sorting")
        "--output", "-o",
        help="epub name if not specified, derived from title",
    parser.add_argument("--series", help="series name")
    parser.add_argument("--index", help="series index")
    parser.add_argument("--creator", help="Creator/Author")
    parser.add_argument("file_names", nargs="+")
    args = parser.parse_args()
    with Jpeg2Epub(args.title, title_sort=args.title_sort,
                   series=args.series, series_idx=args.index,
                   creator=args.creator,  verbose=0) as j2e:
        for file_name in args.file_names:

if __name__ == "__main__":

I think that for a collection of images maybe the EPUB format is not the most suitable.

If you just need the pictures without any text, and if your reader support it (as far as I know, most of them do it), I would suggest using a comic book file format like CBZ or CBR. These are standard ZIP or RAR files (the only difference is that their extension has been renamed) and are aimed at displaying a sequence of images, ordered alphabetically by their filename. Being standard archives, they are as simple as possible to create and to edit/update if you need to, you don't have to worry about any XML file or complex terminal commands.

These formats are aimed at comic books, but you can really put inside any kind of images you want.

And if your reader don't support these formats, you can easily convert them to a PDF, EPUB or MOBI with Calibre with ease.


The following Linux script creates a simple markdown syntax txt file listing all the jpegs, and convert it to epub using calibre.

for i in *.jpg; do echo \![$i]\($i\); done > alljpgs.txt
ebook-convert alljpgs.txt alljpgs.epub --formatting-type markdown
  • 1
    If you want to rescale the image use tablet as output profile, somehthing like ebook-convert alljpgs.txt alljpgs.epub --output-profile tablet --formatting-type markdown Commented Apr 3, 2018 at 5:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.