diff options
| author | Paweł Jastrzębski <[email protected]> | 2017-02-12 09:13:12 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2017-02-12 09:13:12 +0100 |
| commit | 4647fd1f1dc7258a13e02591296af010af8c79e3 (patch) | |
| tree | a95a0f3f36392b5bc57bed2e0557501b2421996b /kindlecomicconverter/comic2ebook.py | |
| parent | Merge pull request #216 from ciromattia/dev (diff) | |
| parent | Updated README + version bump (diff) | |
| download | kcc-4647fd1f1dc7258a13e02591296af010af8c79e3.tar.gz kcc-4647fd1f1dc7258a13e02591296af010af8c79e3.tar.bz2 kcc-4647fd1f1dc7258a13e02591296af010af8c79e3.zip | |
Merge pull request #224 from ciromattia/dev
5.3.0
Diffstat (limited to 'kindlecomicconverter/comic2ebook.py')
| -rwxr-xr-x | kindlecomicconverter/comic2ebook.py | 1185 |
1 files changed, 1185 insertions, 0 deletions
diff --git a/kindlecomicconverter/comic2ebook.py b/kindlecomicconverter/comic2ebook.py new file mode 100755 index 0000000..404ca11 --- /dev/null +++ b/kindlecomicconverter/comic2ebook.py @@ -0,0 +1,1185 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2012-2014 Ciro Mattia Gonano <[email protected]> +# Copyright (c) 2013-2017 Pawel Jastrzebski <[email protected]> +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all +# copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +# DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +# OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. +# + +import os +import sys +from time import strftime, gmtime +from copy import copy +from glob import glob +from json import loads +from urllib.request import Request, urlopen +from re import sub +from stat import S_IWRITE, S_IREAD, S_IEXEC +from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED +from tempfile import mkdtemp, gettempdir, TemporaryFile +from shutil import move, copytree, rmtree +from optparse import OptionParser, OptionGroup +from multiprocessing import Pool +from uuid import uuid4 +from slugify import slugify as slugifyExt +from PIL import Image +from subprocess import STDOUT, PIPE +from psutil import Popen, virtual_memory +from html import escape +try: + from PyQt5 import QtCore +except ImportError: + QtCore = None +try: + from scandir import walk +except ImportError: + walk = os.walk +from .shared import md5Checksum, getImageFileName, walkSort, walkLevel, saferReplace, saferRemove, sanitizeTrace +from . import comic2panel +from . import image +from . import cbxarchive +from . import pdfjpgextract +from . import dualmetafix +from . import metadata +from . import kindle +from . import __version__ + + +def main(argv=None): + global options + parser = makeParser() + optionstemplate, args = parser.parse_args(argv) + if len(args) == 0: + parser.print_help() + return 0 + if sys.platform.startswith('win'): + sources = set([source for arg in args for source in glob(arg)]) + else: + sources = set(args) + if len(sources) == 0: + print('No matching files found.') + return 1 + for source in sources: + source = source.rstrip('\\').rstrip('/') + options = copy(optionstemplate) + checkOptions() + if len(sources) > 1: + print('Working on ' + source + '...') + makeBook(source) + return 0 + + +def buildHTML(path, imgfile, imgfilepath): + imgfilepath = md5Checksum(imgfilepath) + filename = getImageFileName(imgfile) + deviceres = options.profileData[1] + if "Rotated" in options.imgIndex[imgfilepath]: + rotatedPage = True + else: + rotatedPage = False + if "BlackFill" in options.imgIndex[imgfilepath]: + additionalStyle = 'background-color:#000000;' + else: + additionalStyle = 'background-color:#FFFFFF;' + postfix = '' + backref = 1 + head = path + while True: + head, tail = os.path.split(head) + if tail == 'Images': + htmlpath = os.path.join(head, 'Text', postfix) + break + postfix = tail + "/" + postfix + backref += 1 + if not os.path.exists(htmlpath): + os.makedirs(htmlpath) + htmlfile = os.path.join(htmlpath, filename[0] + '.xhtml') + imgsize = Image.open(os.path.join(head, "Images", postfix, imgfile)).size + f = open(htmlfile, "w", encoding='UTF-8') + f.writelines(["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n", + "<!DOCTYPE html>\n", + "<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n", + "<head>\n", + "<title>", escape(filename[0]), "</title>\n", + "<link href=\"", "../" * (backref - 1), "style.css\" type=\"text/css\" rel=\"stylesheet\"/>\n", + "<meta name=\"viewport\" " + "content=\"width=" + str(deviceres[0]) + ", height=" + str(deviceres[1]) + "\"/>\n" + "</head>\n", + "<body style=\"" + additionalStyle + "\">\n", + "<div style=\"text-align:center;top:" + getTopMargin(deviceres, imgsize) + "%;\">\n", + "<img width=\"" + str(imgsize[0]) + "\" height=\"" + str(imgsize[1]) + "\" ", + "src=\"", "../" * backref, "Images/", postfix, imgfile, "\"/>\n</div>\n"]) + if options.iskindle and options.panelview: + if options.autoscale: + size = (getPanelViewResolution(imgsize, deviceres)) + else: + size = (int(imgsize[0] * 1.5), int(imgsize[1] * 1.5)) + if size[0] - deviceres[0] < deviceres[0] * 0.01: + noHorizontalPV = True + else: + noHorizontalPV = False + if size[1] - deviceres[1] < deviceres[1] * 0.01: + noVerticalPV = True + else: + noVerticalPV = False + x, y = getPanelViewSize(deviceres, size) + boxStyles = {"PV-TL": "position:absolute;left:0;top:0;", + "PV-TR": "position:absolute;right:0;top:0;", + "PV-BL": "position:absolute;left:0;bottom:0;", + "PV-BR": "position:absolute;right:0;bottom:0;", + "PV-T": "position:absolute;top:0;left:" + x + "%;", + "PV-B": "position:absolute;bottom:0;left:" + x + "%;", + "PV-L": "position:absolute;left:0;top:" + y + "%;", + "PV-R": "position:absolute;right:0;top:" + y + "%;"} + f.write("<div id=\"PV\">\n") + if not noHorizontalPV and not noVerticalPV: + if rotatedPage: + if options.righttoleft: + order = [1, 3, 2, 4] + else: + order = [2, 4, 1, 3] + else: + if options.righttoleft: + order = [2, 1, 4, 3] + else: + order = [1, 2, 3, 4] + boxes = ["PV-TL", "PV-TR", "PV-BL", "PV-BR"] + elif noHorizontalPV and not noVerticalPV: + if rotatedPage: + if options.righttoleft: + order = [1, 2] + else: + order = [2, 1] + else: + order = [1, 2] + boxes = ["PV-T", "PV-B"] + elif not noHorizontalPV and noVerticalPV: + if rotatedPage: + order = [1, 2] + else: + if options.righttoleft: + order = [2, 1] + else: + order = [1, 2] + boxes = ["PV-L", "PV-R"] + else: + order = [] + boxes = [] + for i in range(0, len(boxes)): + f.writelines(["<div id=\"" + boxes[i] + "\">\n", + "<a style=\"display:inline-block;width:100%;height:100%;\" class=\"app-amzn-magnify\" " + "data-app-amzn-magnify='{\"targetId\":\"" + boxes[i] + + "-P\", \"ordinal\":" + str(order[i]) + "}'></a>\n", + "</div>\n"]) + f.write("</div>\n") + for box in boxes: + f.writelines(["<div class=\"PV-P\" id=\"" + box + "-P\" style=\"" + additionalStyle + "\">\n", + "<img style=\"" + boxStyles[box] + "\" src=\"", "../" * backref, "Images/", postfix, + imgfile, "\" width=\"" + str(size[0]) + "\" height=\"" + str(size[1]) + "\"/>\n", + "</div>\n"]) + f.writelines(["</body>\n", + "</html>\n"]) + f.close() + return path, imgfile + + +def buildNCX(dstdir, title, chapters, chapterNames): + ncxfile = os.path.join(dstdir, 'OEBPS', 'toc.ncx') + f = open(ncxfile, "w", encoding='UTF-8') + f.writelines(["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n", + "<ncx version=\"2005-1\" xml:lang=\"en-US\" xmlns=\"http://www.daisy.org/z3986/2005/ncx/\">\n", + "<head>\n", + "<meta name=\"dtb:uid\" content=\"urn:uuid:", options.uuid, "\"/>\n", + "<meta name=\"dtb:depth\" content=\"1\"/>\n", + "<meta name=\"dtb:totalPageCount\" content=\"0\"/>\n", + "<meta name=\"dtb:maxPageNumber\" content=\"0\"/>\n", + "<meta name=\"generated\" content=\"true\"/>\n", + "</head>\n", + "<docTitle><text>", escape(title), "</text></docTitle>\n", + "<navMap>\n"]) + for chapter in chapters: + folder = chapter[0].replace(os.path.join(dstdir, 'OEBPS'), '').lstrip('/').lstrip('\\\\') + filename = getImageFileName(os.path.join(folder, chapter[1])) + navID = folder.replace('/', '_').replace('\\', '_') + if options.chapters: + title = chapterNames[chapter[1]] + navID = filename[0].replace('/', '_').replace('\\', '_') + elif os.path.basename(folder) != "Text": + title = chapterNames[os.path.basename(folder)] + f.write("<navPoint id=\"" + navID + "\"><navLabel><text>" + + escape(title) + "</text></navLabel><content src=\"" + filename[0].replace("\\", "/") + + ".xhtml\"/></navPoint>\n") + f.write("</navMap>\n</ncx>") + f.close() + + +def buildNAV(dstdir, title, chapters, chapterNames): + navfile = os.path.join(dstdir, 'OEBPS', 'nav.xhtml') + f = open(navfile, "w", encoding='UTF-8') + f.writelines(["<?xml version=\"1.0\" encoding=\"utf-8\"?>\n", + "<!DOCTYPE html>\n", + "<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n", + "<head>\n", + "<title>" + escape(title) + "</title>\n", + "<meta charset=\"utf-8\"/>\n", + "</head>\n", + "<body>\n", + "<nav xmlns:epub=\"http://www.idpf.org/2007/ops\" epub:type=\"toc\" id=\"toc\">\n", + "<ol>\n"]) + for chapter in chapters: + folder = chapter[0].replace(os.path.join(dstdir, 'OEBPS'), '').lstrip('/').lstrip('\\\\') + filename = getImageFileName(os.path.join(folder, chapter[1])) + if options.chapters: + title = chapterNames[chapter[1]] + elif os.path.basename(folder) != "Text": + title = chapterNames[os.path.basename(folder)] + f.write("<li><a href=\"" + filename[0].replace("\\", "/") + ".xhtml\">" + escape(title) + "</a></li>\n") + f.writelines(["</ol>\n", + "</nav>\n", + "<nav epub:type=\"page-list\">\n", + "<ol>\n"]) + for chapter in chapters: + folder = chapter[0].replace(os.path.join(dstdir, 'OEBPS'), '').lstrip('/').lstrip('\\\\') + filename = getImageFileName(os.path.join(folder, chapter[1])) + if options.chapters: + title = chapterNames[chapter[1]] + elif os.path.basename(folder) != "Text": + title = chapterNames[os.path.basename(folder)] + f.write("<li><a href=\"" + filename[0].replace("\\", "/") + ".xhtml\">" + escape(title) + "</a></li>\n") + f.write("</ol>\n</nav>\n</body>\n</html>") + f.close() + + +def buildOPF(dstdir, title, filelist, cover=None): + opffile = os.path.join(dstdir, 'OEBPS', 'content.opf') + deviceres = options.profileData[1] + if options.righttoleft: + writingmode = "horizontal-rl" + else: + writingmode = "horizontal-lr" + f = open(opffile, "w", encoding='UTF-8') + f.writelines(["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n", + "<package version=\"3.0\" unique-identifier=\"BookID\" ", + "xmlns=\"http://www.idpf.org/2007/opf\">\n", + "<metadata xmlns:opf=\"http://www.idpf.org/2007/opf\" ", + "xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n", + "<dc:title>", title, "</dc:title>\n", + "<dc:language>en-US</dc:language>\n", + "<dc:identifier id=\"BookID\">urn:uuid:", options.uuid, "</dc:identifier>\n", + "<dc:contributor id=\"contributor\">KindleComicConverter-" + __version__ + "</dc:contributor>\n"]) + if len(options.summary) > 0: + f.writelines(["<dc:description>", options.summary, "</dc:description>\n"]) + for author in options.authors: + f.writelines(["<dc:creator>", author, "</dc:creator>\n"]) + f.writelines(["<meta property=\"dcterms:modified\">" + strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) + "</meta>\n", + "<meta name=\"cover\" content=\"cover\"/>\n", + "<meta property=\"rendition:orientation\">portrait</meta>\n", + "<meta property=\"rendition:spread\">portrait</meta>\n", + "<meta property=\"rendition:layout\">pre-paginated</meta>\n"]) + if options.iskindle and options.profile != 'Custom': + f.writelines(["<meta name=\"original-resolution\" content=\"", + str(deviceres[0]) + "x" + str(deviceres[1]) + "\"/>\n", + "<meta name=\"book-type\" content=\"comic\"/>\n", + "<meta name=\"RegionMagnification\" content=\"true\"/>\n", + "<meta name=\"primary-writing-mode\" content=\"" + writingmode + "\"/>\n", + "<meta name=\"zero-gutter\" content=\"true\"/>\n", + "<meta name=\"zero-margin\" content=\"true\"/>\n", + "<meta name=\"ke-border-color\" content=\"#ffffff\"/>\n", + "<meta name=\"ke-border-width\" content=\"0\"/>\n"]) + f.writelines(["</metadata>\n<manifest>\n<item id=\"ncx\" href=\"toc.ncx\" ", + "media-type=\"application/x-dtbncx+xml\"/>\n", + "<item id=\"nav\" href=\"nav.xhtml\" ", + "properties=\"nav\" media-type=\"application/xhtml+xml\"/>\n"]) + if cover is not None: + filename = getImageFileName(cover.replace(os.path.join(dstdir, 'OEBPS'), '').lstrip('/').lstrip('\\\\')) + if '.png' == filename[1]: + mt = 'image/png' + else: + mt = 'image/jpeg' + f.write("<item id=\"cover\" href=\"Images/cover" + filename[1] + "\" media-type=\"" + mt + + "\" properties=\"cover-image\"/>\n") + reflist = [] + for path in filelist: + folder = path[0].replace(os.path.join(dstdir, 'OEBPS'), '').lstrip('/').lstrip('\\\\').replace("\\", "/") + filename = getImageFileName(path[1]) + uniqueid = os.path.join(folder, filename[0]).replace('/', '_').replace('\\', '_') + reflist.append(uniqueid) + f.write("<item id=\"page_" + str(uniqueid) + "\" href=\"" + + folder.replace('Images', 'Text') + "/" + filename[0] + + ".xhtml\" media-type=\"application/xhtml+xml\"/>\n") + if '.png' == filename[1]: + mt = 'image/png' + else: + mt = 'image/jpeg' + f.write("<item id=\"img_" + str(uniqueid) + "\" href=\"" + folder + "/" + path[1] + "\" media-type=\"" + + mt + "\"/>\n") + f.write("<item id=\"css\" href=\"Text/style.css\" media-type=\"text/css\"/>\n") + if options.righttoleft: + f.write("</manifest>\n<spine page-progression-direction=\"rtl\" toc=\"ncx\">\n") + else: + f.write("</manifest>\n<spine page-progression-direction=\"ltr\" toc=\"ncx\">\n") + for entry in reflist: + f.write("<itemref idref=\"page_" + entry + "\"/>\n") + f.write("</spine>\n</package>\n") + f.close() + os.mkdir(os.path.join(dstdir, 'META-INF')) + f = open(os.path.join(dstdir, 'META-INF', 'container.xml'), 'w', encoding='UTF-8') + f.writelines(["<?xml version=\"1.0\"?>\n", + "<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n", + "<rootfiles>\n", + "<rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n", + "</rootfiles>\n", + "</container>"]) + f.close() + + +def buildEPUB(path, chapterNames, tomeNumber): + filelist = [] + chapterlist = [] + cover = None + os.mkdir(os.path.join(path, 'OEBPS', 'Text')) + f = open(os.path.join(path, 'OEBPS', 'Text', 'style.css'), 'w', encoding='UTF-8') + f.writelines(["@page {\n", + "margin: 0;\n", + "}\n", + "body {\n", + "display: block;\n", + "margin: 0;\n", + "padding: 0;\n", + "}\n", + "#PV {\n", + "position: absolute;\n", + "width: 100%;\n", + "height: 100%;\n", + "top: 0;\n", + "left: 0;\n", + "}\n", + "#PV-T {\n", + "top: 0;\n", + "width: 100%;\n", + "height: 50%;\n", + "}\n", + "#PV-B {\n", + "bottom: 0;\n", + "width: 100%;\n", + "height: 50%;\n", + "}\n", + "#PV-L {\n", + "left: 0;\n", + "width: 49.5%;\n", + "height: 100%;\n", + "float: left;\n", + "}\n", + "#PV-R {\n", + "right: 0;\n", + "width: 49.5%;\n", + "height: 100%;\n", + "float: right;\n", + "}\n", + "#PV-TL {\n", + "top: 0;\n", + "left: 0;\n", + "width: 49.5%;\n", + "height: 50%;\n", + "float: left;\n", + "}\n", + "#PV-TR {\n", + "top: 0;\n", + "right: 0;\n", + "width: 49.5%;\n", + "height: 50%;\n", + "float: right;\n", + "}\n", + "#PV-BL {\n", + "bottom: 0;\n", + "left: 0;\n", + "width: 49.5%;\n", + "height: 50%;\n", + "float: left;\n", + "}\n", + "#PV-BR {\n", + "bottom: 0;\n", + "right: 0;\n", + "width: 49.5%;\n", + "height: 50%;\n", + "float: right;\n", + "}\n", + ".PV-P {\n", + "width: 100%;\n", + "height: 100%;\n", + "top: 0;\n", + "position: absolute;\n", + "display: none;\n", + "}\n"]) + f.close() + for (dirpath, dirnames, filenames) in walk(os.path.join(path, 'OEBPS', 'Images')): + chapter = False + dirnames, filenames = walkSort(dirnames, filenames) + for afile in filenames: + filelist.append(buildHTML(dirpath, afile, os.path.join(dirpath, afile))) + if not chapter: + chapterlist.append((dirpath.replace('Images', 'Text'), filelist[-1][1])) + chapter = True + if cover is None: + cover = os.path.join(os.path.join(path, 'OEBPS', 'Images'), + 'cover' + getImageFileName(filelist[-1][1])[1]) + options.covers.append((image.Cover(os.path.join(filelist[-1][0], filelist[-1][1]), cover, options, + tomeNumber), options.uuid)) + # Overwrite chapternames if tree is flat and ComicInfo.xml has bookmarks + if not chapterNames and options.chapters: + chapterlist = [] + globaldiff = 0 + for aChapter in options.chapters: + pageid = aChapter[0] + for x in range(0, pageid + globaldiff + 1): + if '-kcc-b' in filelist[x][1]: + pageid += 1 + if '-kcc-c' in filelist[pageid][1]: + pageid -= 1 + filename = filelist[pageid][1] + chapterlist.append((filelist[pageid][0].replace('Images', 'Text'), filename)) + chapterNames[filename] = aChapter[1] + globaldiff = pageid - (aChapter[0] + globaldiff) + buildNCX(path, options.title, chapterlist, chapterNames) + buildNAV(path, options.title, chapterlist, chapterNames) + buildOPF(path, options.title, filelist, cover) + + +def imgDirectoryProcessing(path): + global workerPool, workerOutput + workerPool = Pool() + workerOutput = [] + options.imgIndex = {} + options.imgPurgeIndex = [] + work = [] + pagenumber = 0 + for (dirpath, dirnames, filenames) in walk(path): + for afile in filenames: + pagenumber += 1 + work.append([afile, dirpath, options]) + if GUI: + GUI.progressBarTick.emit(str(pagenumber)) + if len(work) > 0: + for i in work: + workerPool.apply_async(func=imgFileProcessing, args=(i, ), callback=imgFileProcessingTick) + workerPool.close() + workerPool.join() + if GUI and not GUI.conversionAlive: + rmtree(os.path.join(path, '..', '..'), True) + raise UserWarning("Conversion interrupted.") + if len(workerOutput) > 0: + rmtree(os.path.join(path, '..', '..'), True) + raise RuntimeError("One of workers crashed. Cause: " + workerOutput[0][0], workerOutput[0][1]) + for file in options.imgPurgeIndex: + if os.path.isfile(file): + saferRemove(file) + else: + rmtree(os.path.join(path, '..', '..'), True) + raise UserWarning("Source directory is empty.") + + +def imgFileProcessingTick(output): + if isinstance(output, tuple): + workerOutput.append(output) + workerPool.terminate() + else: + for page in output: + if page is not None: + options.imgIndex[page[0]] = page[1] + options.imgPurgeIndex.append(page[2]) + if GUI: + GUI.progressBarTick.emit('tick') + if not GUI.conversionAlive: + workerPool.terminate() + + +def imgFileProcessing(work): + try: + afile = work[0] + dirpath = work[1] + opt = work[2] + output = [] + workImg = image.ComicPageParser((dirpath, afile), opt) + for i in workImg.payload: + img = image.ComicPage(i[0], i[1], i[2], i[3], i[4], opt) + if opt.cropping == 2 and not opt.webtoon: + img.cropPageNumber(opt.croppingp) + if opt.cropping > 0 and not opt.webtoon: + img.cropMargin(opt.croppingp) + img.autocontrastImage() + img.resizeImage() + if opt.forcepng and not opt.forcecolor: + img.quantizeImage() + output.append(img.saveToDir()) + return output + except Exception: + return str(sys.exc_info()[1]), sanitizeTrace(sys.exc_info()[2]) + + +def getWorkFolder(afile): + if os.path.isdir(afile): + workdir = mkdtemp('', 'KCC-') + try: + os.rmdir(workdir) + fullPath = os.path.join(workdir, 'OEBPS', 'Images') + copytree(afile, fullPath) + sanitizePermissions(fullPath) + return workdir + except: + rmtree(workdir, True) + raise UserWarning("Failed to prepare a workspace.") + elif os.path.isfile(afile) and afile.lower().endswith('.pdf'): + pdf = pdfjpgextract.PdfJpgExtract(afile) + path, njpg = pdf.extract() + if njpg == 0: + rmtree(path, True) + raise UserWarning("Failed to extract images from PDF file.") + elif os.path.isfile(afile): + workdir = mkdtemp('', 'KCC-') + cbx = cbxarchive.CBxArchive(afile) + if cbx.isCbxFile(): + try: + path = cbx.extract(workdir) + except: + rmtree(workdir, True) + raise UserWarning("Failed to extract archive.") + else: + rmtree(workdir, True) + raise UserWarning("Failed to detect archive format.") + else: + raise UserWarning("Failed to open source file/directory.") + sanitizePermissions(path) + newpath = mkdtemp('', 'KCC-') + copytree(path, os.path.join(newpath, 'OEBPS', 'Images')) + rmtree(path, True) + return newpath + + +def getOutputFilename(srcpath, wantedname, ext, tomeNumber): + if srcpath[-1] == os.path.sep: + srcpath = srcpath[:-1] + if 'Ko' in options.profile and options.format == 'EPUB': + ext = '.kepub.epub' + if wantedname is not None: + if wantedname.endswith(ext): + filename = os.path.abspath(wantedname) + elif os.path.isdir(srcpath): + filename = os.path.join(os.path.abspath(options.output), os.path.basename(srcpath) + ext) + else: + filename = os.path.join(os.path.abspath(options.output), + os.path.basename(os.path.splitext(srcpath)[0]) + ext) + elif os.path.isdir(srcpath): + filename = srcpath + tomeNumber + ext + else: + if 'Ko' in options.profile and options.format == 'EPUB': + path = srcpath.split(os.path.sep) + path[-1] = ''.join(e for e in path[-1].split('.')[0] if e.isalnum()) + tomeNumber + ext + if not path[-1].split('.')[0]: + path[-1] = 'KCCPlaceholder' + tomeNumber + ext + filename = os.path.sep.join(path) + else: + filename = os.path.splitext(srcpath)[0] + tomeNumber + ext + if os.path.isfile(filename): + counter = 0 + basename = os.path.splitext(filename)[0] + while os.path.isfile(basename + '_kcc' + str(counter) + ext): + counter += 1 + filename = basename + '_kcc' + str(counter) + ext + return filename + + +def getComicInfo(path, originalPath): + xmlPath = os.path.join(path, 'ComicInfo.xml') + options.authors = ['KCC'] + options.remoteCovers = {} + options.chapters = [] + options.summary = '' + titleSuffix = '' + if options.title == 'defaulttitle': + defaultTitle = True + if os.path.isdir(originalPath): + options.title = os.path.basename(originalPath) + else: + options.title = os.path.splitext(os.path.basename(originalPath))[0] + else: + defaultTitle = False + if os.path.exists(xmlPath): + try: + xml = metadata.MetadataParser(xmlPath) + except Exception: + saferRemove(xmlPath) + return + options.authors = [] + if defaultTitle: + if xml.data['Series']: + options.title = escape(xml.data['Series']) + if xml.data['Volume']: + titleSuffix += ' V' + xml.data['Volume'].zfill(2) + if xml.data['Number']: + titleSuffix += ' #' + xml.data['Number'].zfill(3) + options.title += titleSuffix + for field in ['Writers', 'Pencillers', 'Inkers', 'Colorists']: + for person in xml.data[field]: + options.authors.append(escape(person)) + if len(options.authors) > 0: + options.authors = list(set(options.authors)) + options.authors.sort() + else: + options.authors = ['KCC'] + if xml.data['MUid']: + options.remoteCovers = getCoversFromMCB(xml.data['MUid']) + if xml.data['Bookmarks']: + options.chapters = xml.data['Bookmarks'] + if xml.data['Summary']: + options.summary = escape(xml.data['Summary']) + saferRemove(xmlPath) + + +def getCoversFromMCB(mangaID): + covers = {} + try: + jsonRaw = urlopen(Request('http://mcd.iosphe.re/api/v1/series/' + mangaID + '/', + headers={'User-Agent': 'KindleComicConverter/' + __version__})) + jsonData = loads(jsonRaw.read().decode('utf-8')) + for volume in jsonData['Covers']['a']: + if volume['Side'] == 'front': + covers[int(volume['Volume'])] = volume['Raw'] + except Exception: + return {} + return covers + + +def getDirectorySize(start_path='.'): + total_size = 0 + for dirpath, dirnames, filenames in walk(start_path): + for f in filenames: + fp = os.path.join(dirpath, f) + total_size += os.path.getsize(fp) + return total_size + + +def getTopMargin(deviceres, size): + y = int((deviceres[1] - size[1]) / 2) / deviceres[1] * 100 + return str(round(y, 1)) + + +def getPanelViewResolution(imageSize, deviceRes): + scale = float(deviceRes[0]) / float(imageSize[0]) + return int(deviceRes[0]), int(scale * imageSize[1]) + + +def getPanelViewSize(deviceres, size): + x = int(deviceres[0] / 2 - size[0] / 2) / deviceres[0] * 100 + y = int(deviceres[1] / 2 - size[1] / 2) / deviceres[1] * 100 + return str(int(x)), str(int(y)) + + +def sanitizeTree(filetree): + chapterNames = {} + for root, dirs, files in walk(filetree, False): + for name in files: + splitname = os.path.splitext(name) + slugified = slugify(splitname[0]) + while os.path.exists(os.path.join(root, slugified + splitname[1])) and splitname[0].upper()\ + != slugified.upper(): + slugified += "A" + newKey = os.path.join(root, slugified + splitname[1]) + key = os.path.join(root, name) + if key != newKey: + saferReplace(key, newKey) + for name in dirs: + tmpName = name + slugified = slugify(name) + while os.path.exists(os.path.join(root, slugified)) and name.upper() != slugified.upper(): + slugified += "A" + chapterNames[slugified] = tmpName + newKey = os.path.join(root, slugified) + key = os.path.join(root, name) + if key != newKey: + saferReplace(key, newKey) + return chapterNames + + +def sanitizeTreeKobo(filetree): + pageNumber = 0 + for root, dirs, files in walk(filetree): + dirs, files = walkSort(dirs, files) + for name in files: + splitname = os.path.splitext(name) + slugified = str(pageNumber).zfill(5) + pageNumber += 1 + while os.path.exists(os.path.join(root, slugified + splitname[1])) and splitname[0].upper()\ + != slugified.upper(): + slugified += "A" + newKey = os.path.join(root, slugified + splitname[1]) + key = os.path.join(root, name) + if key != newKey: + saferReplace(key, newKey) + + +def sanitizePermissions(filetree): + for root, dirs, files in walk(filetree, False): + for name in files: + os.chmod(os.path.join(root, name), S_IWRITE | S_IREAD) + for name in dirs: + os.chmod(os.path.join(root, name), S_IWRITE | S_IREAD | S_IEXEC) + + +def splitDirectory(path): + level = -1 + for root, _, files in os.walk(os.path.join(path, 'OEBPS', 'Images')): + for f in files: + if f.endswith('.jpg') or f.endswith('.jpeg') or f.endswith('.png') or f.endswith('.gif'): + newLevel = os.path.join(root, f).replace(os.path.join(path, 'OEBPS', 'Images'), '').count(os.sep) + if level != -1 and level != newLevel: + level = 0 + break + else: + level = newLevel + if level > 0: + splitter = splitProcess(os.path.join(path, 'OEBPS', 'Images'), level) + path = [path] + for tome in splitter: + path.append(tome) + return path + else: + raise UserWarning('Unsupported directory structure.') + + +def splitProcess(path, mode): + output = [] + currentSize = 0 + currentTarget = path + if options.webtoon: + targetSize = 104857600 + else: + targetSize = 419430400 + if options.batchsplit == 2 and mode == 2: + mode = 3 + if mode < 3: + for root, dirs, files in walkLevel(path, 0): + for name in files if mode == 1 else dirs: + if mode == 1: + size = os.path.getsize(os.path.join(root, name)) + else: + size = getDirectorySize(os.path.join(root, name)) + if currentSize + size > targetSize: + currentTarget, pathRoot = createNewTome() + output.append(pathRoot) + currentSize = size + else: + currentSize += size + if path != currentTarget: + move(os.path.join(root, name), os.path.join(currentTarget, name)) + else: + firstTome = True + for root, dirs, files in walkLevel(path, 0): + for name in dirs: + if not firstTome: + currentTarget, pathRoot = createNewTome() + output.append(pathRoot) + move(os.path.join(root, name), os.path.join(currentTarget, name)) + else: + firstTome = False + return output + + +def detectCorruption(tmpPath, orgPath): + imageNumber = 0 + imageSmaller = 0 + for root, dirs, files in walk(tmpPath, False): + for name in files: + if getImageFileName(name) is not None: + path = os.path.join(root, name) + pathOrg = orgPath + path.split('OEBPS' + os.path.sep + 'Images')[1] + if os.path.getsize(path) == 0: + rmtree(os.path.join(tmpPath, '..', '..'), True) + raise RuntimeError('Image file %s is corrupted.' % pathOrg) + try: + img = Image.open(path) + img.verify() + img = Image.open(path) + img.load() + imageNumber += 1 + if options.profileData[1][0] > img.size[0] and options.profileData[1][1] > img.size[1]: + imageSmaller += 1 + except Exception as err: + rmtree(os.path.join(tmpPath, '..', '..'), True) + if 'decoder' in str(err) and 'not available' in str(err): + raise RuntimeError('Pillow was compiled without JPG and/or PNG decoder.') + else: + raise RuntimeError('Image file %s is corrupted.' % pathOrg) + else: + saferRemove(os.path.join(root, name)) + if imageSmaller > imageNumber * 0.25 and not options.upscale and not options.stretch: + print("WARNING: More than 25% of images are smaller than target device resolution. " + "Consider enabling stretching or upscaling to improve readability.") + if GUI: + GUI.addMessage.emit('More than 25% of images are smaller than target device resolution.', 'warning', False) + GUI.addMessage.emit('Consider enabling stretching or upscaling to improve readability.', 'warning', False) + GUI.addMessage.emit('', '', False) + + +def createNewTome(): + tomePathRoot = mkdtemp('', 'KCC-') + tomePath = os.path.join(tomePathRoot, 'OEBPS', 'Images') + os.makedirs(tomePath) + return tomePath, tomePathRoot + + +def slugify(value): + value = slugifyExt(value) + value = sub(r'0*([0-9]{4,})', r'\1', sub(r'([0-9]+)', r'0000\1', value, count=2)) + return value + + +def makeZIP(zipFilename, baseDir, isEPUB=False): + zipFilename = os.path.abspath(zipFilename) + '.zip' + zipOutput = ZipFile(zipFilename, 'w', ZIP_DEFLATED) + if isEPUB: + zipOutput.writestr('mimetype', 'application/epub+zip', ZIP_STORED) + for dirpath, dirnames, filenames in walk(baseDir): + for name in filenames: + path = os.path.normpath(os.path.join(dirpath, name)) + aPath = os.path.normpath(os.path.join(dirpath.replace(baseDir, ''), name)) + if os.path.isfile(path): + zipOutput.write(path, aPath) + zipOutput.close() + return zipFilename + + +def makeParser(): + psr = OptionParser(usage="Usage: kcc-c2e [options] comic_file|comic_folder", add_help_option=False) + + mainOptions = OptionGroup(psr, "MAIN") + processingOptions = OptionGroup(psr, "PROCESSING") + outputOptions = OptionGroup(psr, "OUTPUT SETTINGS") + customProfileOptions = OptionGroup(psr, "CUSTOM PROFILE") + otherOptions = OptionGroup(psr, "OTHER") + + mainOptions.add_option("-p", "--profile", action="store", dest="profile", default="KV", + help="Device profile (Available options: K1, K2, K3, K45, KDX, KPW, KV, KoMT, KoG, KoGHD," + " KoA, KoAHD, KoAH2O, KoAO) [Default=KV]") + mainOptions.add_option("-m", "--manga-style", action="store_true", dest="righttoleft", default=False, + help="Manga style (right-to-left reading and splitting)") + mainOptions.add_option("-2", "--two-panel", action="store_true", dest="autoscale", default=False, + help="Display two not four panels in Panel View mode") + mainOptions.add_option("-w", "--webtoon", action="store_true", dest="webtoon", default=False, + help="Webtoon processing mode"), + + outputOptions.add_option("-o", "--output", action="store", dest="output", default=None, + help="Output generated file to specified directory or file") + outputOptions.add_option("-t", "--title", action="store", dest="title", default="defaulttitle", + help="Comic title [Default=filename or directory name]") + outputOptions.add_option("-f", "--format", action="store", dest="format", default="Auto", + help="Output format (Available options: Auto, MOBI, EPUB, CBZ) [Default=Auto]") + outputOptions.add_option("-b", "--batchsplit", type="int", dest="batchsplit", default="0", + help="Split output into multiple files. 0: Don't split 1: Automatic mode " + "2: Consider every subdirectory as separate volume [Default=0]") + + processingOptions.add_option("-u", "--upscale", action="store_true", dest="upscale", default=False, + help="Resize images smaller than device's resolution") + processingOptions.add_option("-s", "--stretch", action="store_true", dest="stretch", default=False, + help="Stretch images to device's resolution") + processingOptions.add_option("-r", "--splitter", type="int", dest="splitter", default="0", + help="Double page parsing mode. 0: Split 1: Rotate 2: Both [Default=0]") + processingOptions.add_option("-g", "--gamma", type="float", dest="gamma", default="0.0", + help="Apply gamma correction to linearize the image [Default=Auto]") + processingOptions.add_option("-c", "--cropping", type="int", dest="cropping", default="2", + help="Set cropping mode. 0: Disabled 1: Margins 2: Margins + page numbers [Default=2]") + processingOptions.add_option("--cp", "--croppingpower", type="float", dest="croppingp", default="1.0", + help="Set cropping power [Default=1.0]") + processingOptions.add_option("--blackborders", action="store_true", dest="black_borders", default=False, + help="Disable autodetection and force black borders") + processingOptions.add_option("--whiteborders", action="store_true", dest="white_borders", default=False, + help="Disable autodetection and force white borders") + processingOptions.add_option("--forcecolor", action="store_true", dest="forcecolor", default=False, + help="Don't convert images to grayscale") + processingOptions.add_option("--forcepng", action="store_true", dest="forcepng", default=False, + help="Create PNG files instead JPEG") + + customProfileOptions.add_option("--customwidth", type="int", dest="customwidth", default=0, + help="Replace screen width provided by device profile") + customProfileOptions.add_option("--customheight", type="int", dest="customheight", default=0, + help="Replace screen height provided by device profile") + + otherOptions.add_option("-h", "--help", action="help", + help="Show this help message and exit") + + psr.add_option_group(mainOptions) + psr.add_option_group(outputOptions) + psr.add_option_group(processingOptions) + psr.add_option_group(customProfileOptions) + psr.add_option_group(otherOptions) + return psr + + +def checkOptions(): + global options + options.panelview = True + options.iskindle = False + options.bordersColor = None + if options.format == 'Auto': + if options.profile in ['K1', 'K2', 'K3', 'K45', 'KPW', 'KV']: + options.format = 'MOBI' + elif options.profile in ['OTHER', 'KoMT', 'KoG', 'KoGHD', 'KoA', 'KoAHD', 'KoAH2O', 'KoAO']: + options.format = 'EPUB' + elif options.profile in ['KDX']: + options.format = 'CBZ' + if options.profile in ['K1', 'K2', 'K3', 'K45', 'KPW', 'KV']: + options.iskindle = True + if options.white_borders: + options.bordersColor = 'white' + if options.black_borders: + options.bordersColor = 'black' + # Splitting MOBI is not optional + if options.format == 'MOBI' and options.batchsplit != 2: + options.batchsplit = 1 + # Older Kindle models don't support Panel View. + if options.profile == 'K1' or options.profile == 'K2' or options.profile == 'KDX': + options.panelview = False + # Webtoon mode mandatory options + if options.webtoon: + options.panelview = False + options.righttoleft = False + options.upscale = True + # Disable all Kindle features for other e-readers + if options.profile == 'OTHER': + options.panelview = False + if 'Ko' in options.profile: + options.panelview = False + # CBZ files on Kindle DX/DXG support higher resolution + if options.profile == 'KDX' and options.format == 'CBZ': + options.customheight = 1200 + # Override profile data + if options.customwidth != 0 or options.customheight != 0: + X = image.ProfileData.Profiles[options.profile][1][0] + Y = image.ProfileData.Profiles[options.profile][1][1] + if options.customwidth != 0: + X = options.customwidth + if options.customheight != 0: + Y = options.customheight + newProfile = ("Custom", (int(X), int(Y)), image.ProfileData.Palette16, + image.ProfileData.Profiles[options.profile][3]) + image.ProfileData.Profiles["Custom"] = newProfile + options.profile = "Custom" + options.profileData = image.ProfileData.Profiles[options.profile] + + +def checkTools(source): + source = source.upper() + if source.endswith('.CBR') or source.endswith('.RAR'): + rarExitCode = Popen('unrar', stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True) + rarExitCode = rarExitCode.wait() + if rarExitCode != 0 and rarExitCode != 7: + print('ERROR: UnRAR is missing!') + exit(1) + elif source.endswith('.CB7') or source.endswith('.7Z'): + sevenzaExitCode = Popen('7za', stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True) + sevenzaExitCode = sevenzaExitCode.wait() + if sevenzaExitCode != 0 and sevenzaExitCode != 7: + print('ERROR: 7za is missing!') + exit(1) + if options.format == 'MOBI': + kindleGenExitCode = Popen('kindlegen -locale en', stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True) + if kindleGenExitCode.wait() != 0: + print('ERROR: KindleGen is missing!') + exit(1) + + +def checkPre(source): + # Make sure that all temporary files are gone + for root, dirs, _ in walkLevel(gettempdir(), 0): + for tempdir in dirs: + if tempdir.startswith('KCC-'): + rmtree(os.path.join(root, tempdir), True) + # Make sure that target directory is writable + if os.path.isdir(source): + src = os.path.abspath(os.path.join(source, '..')) + else: + src = os.path.dirname(source) + try: + with TemporaryFile(prefix='KCC-', dir=src): + pass + except: + raise UserWarning("Target directory is not writable.") + + +def makeBook(source, qtGUI=None): + global GUI + GUI = qtGUI + if GUI: + GUI.progressBarTick.emit('1') + else: + checkTools(source) + checkPre(source) + print("Preparing source images...") + path = getWorkFolder(source) + print("Checking images...") + getComicInfo(os.path.join(path, "OEBPS", "Images"), source) + detectCorruption(os.path.join(path, "OEBPS", "Images"), source) + if options.webtoon: + if image.ProfileData.Profiles[options.profile][1][1] > 1024: + y = 1024 + else: + y = image.ProfileData.Profiles[options.profile][1][1] + comic2panel.main(['-y ' + str(y), '-i', '-m', path], qtGUI) + print("Processing images...") + if GUI: + GUI.progressBarTick.emit('Processing images') + imgDirectoryProcessing(os.path.join(path, "OEBPS", "Images")) + if GUI: + GUI.progressBarTick.emit('1') + chapterNames = sanitizeTree(os.path.join(path, 'OEBPS', 'Images')) + if 'Ko' in options.profile and options.format == 'CBZ': + sanitizeTreeKobo(os.path.join(path, 'OEBPS', 'Images')) + if options.batchsplit > 0: + tomes = splitDirectory(path) + else: + tomes = [path] + filepath = [] + tomeNumber = 0 + if GUI: + if options.format == 'CBZ': + GUI.progressBarTick.emit('Compressing CBZ files') + else: + GUI.progressBarTick.emit('Compressing EPUB files') + GUI.progressBarTick.emit(str(len(tomes) + 1)) + GUI.progressBarTick.emit('tick') + options.baseTitle = options.title + options.covers = [] + for tome in tomes: + options.uuid = str(uuid4()) + if len(tomes) > 9: + tomeNumber += 1 + options.title = options.baseTitle + ' [' + str(tomeNumber).zfill(2) + '/' + str(len(tomes)).zfill(2) + ']' + elif len(tomes) > 1: + tomeNumber += 1 + options.title = options.baseTitle + ' [' + str(tomeNumber) + '/' + str(len(tomes)) + ']' + if options.format == 'CBZ': + print("Creating CBZ file...") + if len(tomes) > 1: + filepath.append(getOutputFilename(source, options.output, '.cbz', ' ' + str(tomeNumber))) + else: + filepath.append(getOutputFilename(source, options.output, '.cbz', '')) + makeZIP(tome + '_comic', os.path.join(tome, "OEBPS", "Images")) + else: + print("Creating EPUB file...") + buildEPUB(tome, chapterNames, tomeNumber) + if len(tomes) > 1: + filepath.append(getOutputFilename(source, options.output, '.epub', ' ' + str(tomeNumber))) + else: + filepath.append(getOutputFilename(source, options.output, '.epub', '')) + makeZIP(tome + '_comic', tome, True) + move(tome + '_comic.zip', filepath[-1]) + rmtree(tome, True) + if GUI: + GUI.progressBarTick.emit('tick') + if not GUI and options.format == 'MOBI': + print("Creating MOBI files...") + work = [] + for i in filepath: + work.append([i]) + output = makeMOBI(work, GUI) + for errors in output: + if errors[0] != 0: + print('Error: KindleGen failed to create MOBI!') + print(errors) + return filepath + k = kindle.Kindle() + if k.path and k.coverSupport: + print("Kindle detected. Uploading covers...") + for i in filepath: + output = makeMOBIFix(i, options.covers[filepath.index(i)][1]) + if not output[0]: + print('Error: Failed to tweak KindleGen output!') + return filepath + else: + saferRemove(i.replace('.epub', '.mobi') + '_toclean') + if k.path and k.coverSupport: + options.covers[filepath.index(i)][0].saveToKindle(k, options.covers[filepath.index(i)][1]) + return filepath + + +def makeMOBIFix(item, uuid): + saferRemove(item) + mobiPath = item.replace('.epub', '.mobi') + move(mobiPath, mobiPath + '_toclean') + try: + dualmetafix.DualMobiMetaFix(mobiPath + '_toclean', mobiPath, bytes(uuid, 'UTF-8')) + return [True] + except Exception as err: + return [False, format(err)] + + +def makeMOBIWorkerTick(output): + makeMOBIWorkerOutput.append(output) + if output[0] != 0: + makeMOBIWorkerPool.terminate() + if GUI: + GUI.progressBarTick.emit('tick') + if not GUI.conversionAlive: + makeMOBIWorkerPool.terminate() + + +def makeMOBIWorker(item): + item = item[0] + kindlegenErrorCode = 0 + kindlegenError = '' + try: + if os.path.getsize(item) < 629145600: + output = Popen('kindlegen -dont_append_source -locale en "' + item + '"', + stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True) + for line in output.stdout: + line = line.decode('utf-8') + # ERROR: Generic error + if "Error(" in line: + kindlegenErrorCode = 1 + kindlegenError = line + # ERROR: EPUB too big + if ":E23026:" in line: + kindlegenErrorCode = 23026 + if kindlegenErrorCode > 0: + break + if ":I1036: Mobi file built successfully" in line: + output.terminate() + else: + # ERROR: EPUB too big + kindlegenErrorCode = 23026 + return [kindlegenErrorCode, kindlegenError, item] + except Exception as err: + # ERROR: KCC unknown generic error + kindlegenErrorCode = 1 + kindlegenError = format(err) + return [kindlegenErrorCode, kindlegenError, item] + + +def makeMOBI(work, qtGUI=None): + global GUI, makeMOBIWorkerPool, makeMOBIWorkerOutput + GUI = qtGUI + makeMOBIWorkerOutput = [] + availableMemory = virtual_memory().total / 1000000000 + if availableMemory <= 2: + threadNumber = 1 + elif 2 < availableMemory <= 4: + threadNumber = 2 + else: + threadNumber = 4 + makeMOBIWorkerPool = Pool(threadNumber) + for i in work: + makeMOBIWorkerPool.apply_async(func=makeMOBIWorker, args=(i, ), callback=makeMOBIWorkerTick) + makeMOBIWorkerPool.close() + makeMOBIWorkerPool.join() + return makeMOBIWorkerOutput |