From 704dcd6dbe7643f649d5cf8358e9e3cfa2b75b3c Mon Sep 17 00:00:00 2001 From: Belgian Coder <32089114+Belgian-Coder@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:36:33 +0200 Subject: [PATCH] Add PDF output support (#1032) * Add PDF output support * optimize * use with statement * OS_SORT_KEY * fix import * simplify * fix None * fix conditional --------- Co-authored-by: Belgian Coder <> Co-authored-by: Alex Xu --- README.md | 8 +++- kindlecomicconverter/KCC_gui.py | 12 +++-- kindlecomicconverter/comic2ebook.py | 69 ++++++++++++++++++++++++----- requirements.txt | 2 +- setup.py | 1 + 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e4dbd53..36194b9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ like Kindle, Kobo, ReMarkable, and more. Pages display in fullscreen without margins, with proper fixed layout support. Supported input formats include JPG/PNG/GIF image files in folders, archives, or PDFs. -Supported output formats include MOBI/AZW3, EPUB, KEPUB, and CBZ. +Supported output formats include MOBI/AZW3, EPUB, KEPUB, CBZ, and PDF. + +**NEW**: PDF output is now supported for direct conversion to reMarkable devices! +When using a reMarkable profile (Rmk1, Rmk2, RmkPP), the format automatically defaults to PDF +for optimal compatibility with your device's native PDF reader. The absolute highest quality source files are print quality DRM-free PDFs from Kodansha/[Humble Bundle](https://humblebundleinc.sjv.io/xL6Zv1)/Fanatical, which can be directly converted by KCC in v9+. Be sure to check the spread shift option! @@ -256,7 +260,7 @@ OUTPUT SETTINGS: -a AUTHOR, --author AUTHOR Author name [Default=KCC] -f FORMAT, --format FORMAT - Output format (Available options: Auto, MOBI, EPUB, CBZ, KFX, MOBI+EPUB) [Default=Auto] + Output format (Available options: Auto, MOBI, EPUB, CBZ, PDF, KFX, MOBI+EPUB) [Default=Auto] --nokepub If format is EPUB, output file with '.epub' extension rather than '.kepub.epub' -b BATCHSPLIT, --batchsplit BATCHSPLIT Split output into multiple files. 0: Don't split 1: Automatic mode 2: Consider every subdirectory as separate volume [Default=0] diff --git a/kindlecomicconverter/KCC_gui.py b/kindlecomicconverter/KCC_gui.py index 364052b..c96f128 100644 --- a/kindlecomicconverter/KCC_gui.py +++ b/kindlecomicconverter/KCC_gui.py @@ -324,6 +324,9 @@ class WorkerThread(QThread): if gui_current_format == 'CBZ': MW.addMessage.emit('Creating CBZ files', 'info', False) GUI.progress.content = 'Creating CBZ files' + elif gui_current_format == 'PDF': + MW.addMessage.emit('Creating PDF files', 'info', False) + GUI.progress.content = 'Creating PDF files' else: MW.addMessage.emit('Creating EPUB files', 'info', False) GUI.progress.content = 'Creating EPUB files' @@ -368,6 +371,8 @@ class WorkerThread(QThread): GUI.progress.content = '' if gui_current_format == 'CBZ': MW.addMessage.emit('Creating CBZ files... Done!', 'info', True) + elif gui_current_format == 'PDF': + MW.addMessage.emit('Creating PDF files... Done!', 'info', True) else: MW.addMessage.emit('Creating EPUB files... Done!', 'info', True) if 'MOBI' in gui_current_format: @@ -1011,6 +1016,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow): "MOBI/AZW3": {'icon': 'MOBI', 'format': 'MOBI'}, "EPUB": {'icon': 'EPUB', 'format': 'EPUB'}, "CBZ": {'icon': 'CBZ', 'format': 'CBZ'}, + "PDF": {'icon': 'EPUB', 'format': 'PDF'}, "KFX (does not work)": {'icon': 'KFX', 'format': 'KFX'}, "MOBI + EPUB": {'icon': 'MOBI', 'format': 'MOBI+EPUB'}, "EPUB (200MB limit)": {'icon': 'EPUB', 'format': 'EPUB-200MB'}, @@ -1092,11 +1098,11 @@ class KCCGUI(KCC_ui.Ui_mainWindow): 'Label': 'KoS'}, "Kobo Elipsa": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KoE'}, - "reMarkable 1": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': False, + "reMarkable 1": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 3, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'Rmk1'}, - "reMarkable 2": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': False, + "reMarkable 2": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 3, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'Rmk2'}, - "reMarkable Paper Pro": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': True, + "reMarkable Paper Pro": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 3, 'DefaultUpscale': True, 'ForceColor': True, 'Label': 'RmkPP'}, "Other": {'PVOptions': False, 'ForceExpert': True, 'DefaultFormat': 1, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'OTHER'}, diff --git a/kindlecomicconverter/comic2ebook.py b/kindlecomicconverter/comic2ebook.py index 4c8a598..d506c37 100755 --- a/kindlecomicconverter/comic2ebook.py +++ b/kindlecomicconverter/comic2ebook.py @@ -582,6 +582,34 @@ def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, len buildOPF(path, options.title, filelist, cover) +def buildPDF(path, title, cover=None, output_file=None): + """ + Build a PDF file from processed comic images. + Images are combined into a single PDF optimized for e-readers. + """ + start = perf_counter() + # open empty PDF + with pymupdf.open() as doc: + # Stream images to PDF + for root, dirs, files in os.walk(os.path.join(path, "OEBPS", "Images")): + files.sort(key=OS_SORT_KEY) + dirs.sort(key=OS_SORT_KEY) + for file in files: + w, h = Image.open(os.path.join(root, file)).size + page = doc.new_page(width=w, height=h) + page.insert_image(page.rect, filename=os.path.join(root, file)) + + # determine output filename if not provided + if output_file is None: + output_file = getOutputFilename(path, None, '.pdf', '') + + # Save with optimizations for smaller file size + doc.save(output_file, deflate=True, garbage=4, clean=True) + end = perf_counter() + print(f"MuPDF output: {end-start} sec") + return output_file + + def imgDirectoryProcessing(path): global workerPool, workerOutput workerPool = Pool(maxtasksperchild=100) @@ -659,7 +687,8 @@ def imgFileProcessing(work): pass elif opt.forcepng: img.convertToGrayscale() - img.quantizeImage() + if opt.format != 'PDF': + img.quantizeImage() else: img.convertToGrayscale() output.append(img.saveToDir()) @@ -1137,6 +1166,7 @@ def detectSuboptimalProcessing(tmppath, orgpath): try: img = Image.open(path) imageNumber += 1 + # count images smaller than device resolution if options.profileData[1][0] > img.size[0] and options.profileData[1][1] > img.size[1]: imageSmaller += 1 except Exception as err: @@ -1184,7 +1214,6 @@ def slugify(value, is_natural_sorted): 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): start = perf_counter() zipfilename = os.path.abspath(zipfilename) + '.zip' @@ -1209,7 +1238,6 @@ def makeZIP(zipfilename, basedir, isepub=False): print(f"makeZIP time: {end - start} seconds") return zipfilename - def makeParser(): psr = ArgumentParser(prog="kcc-c2e", usage="kcc-c2e [options] [input]", add_help=False) @@ -1247,7 +1275,7 @@ def makeParser(): output_options.add_argument("-a", "--author", action="store", dest="author", default="defaultauthor", help="Author name [Default=KCC]") output_options.add_argument("-f", "--format", action="store", dest="format", default="Auto", - help="Output format (Available options: Auto, MOBI, EPUB, CBZ, KFX, MOBI+EPUB) " + help="Output format (Available options: Auto, MOBI, EPUB, CBZ, KFX, MOBI+EPUB, PDF) " "[Default=Auto]") output_options.add_argument("--nokepub", action="store_true", dest="noKepub", default=False, help="If format is EPUB, output file with '.epub' extension rather than '.kepub.epub'") @@ -1337,6 +1365,8 @@ def checkOptions(options): options.format = 'CBZ' elif options.profile in image.ProfileData.ProfilesKindle.keys(): options.format = 'MOBI' + elif options.profile in image.ProfileData.ProfilesRemarkable.keys(): + options.format = 'PDF' else: options.format = 'EPUB' if options.profile in image.ProfileData.ProfilesKindle.keys(): @@ -1500,6 +1530,8 @@ def makeBook(source, qtgui=None): if GUI: if options.format == 'CBZ': GUI.progressBarTick.emit('Compressing CBZ files') + elif options.format == 'PDF': + GUI.progressBarTick.emit('Creating PDF files') else: GUI.progressBarTick.emit('Compressing EPUB files') GUI.progressBarTick.emit(str(len(tomes) + 1)) @@ -1521,6 +1553,14 @@ def makeBook(source, qtgui=None): else: filepath.append(getOutputFilename(source, options.output, '.cbz', '')) makeZIP(tome + '_comic', os.path.join(tome, "OEBPS", "Images")) + elif options.format == 'PDF': + print("Creating PDF file with PyMuPDF...") + # determine output filename based on source and tome count + suffix = (' ' + str(tomeNumber)) if len(tomes) > 1 else '' + output_file = getOutputFilename(source, options.output, '.pdf', suffix) + # use optimized buildPDF logic with streaming and compression + output_pdf = buildPDF(tome, options.title, None, output_file) + filepath.append(output_pdf) else: print("Creating EPUB file...") if len(tomes) > 1: @@ -1530,12 +1570,14 @@ def makeBook(source, qtgui=None): buildEPUB(tome, chapterNames, tomeNumber, False, cover) filepath.append(getOutputFilename(source, options.output, '.epub', '')) makeZIP(tome + '_comic', tome, True) - copyfile(tome + '_comic.zip', filepath[-1]) - try: - os.remove(tome + '_comic.zip') - except FileNotFoundError: - # newly temporary created file is not found. It might have been already deleted - pass + # Copy files to final destination (PDF files are already saved directly) + if options.format != 'PDF': + copyfile(tome + '_comic.zip', filepath[-1]) + try: + os.remove(tome + '_comic.zip') + except FileNotFoundError: + # newly temporary created file is not found. It might have been already deleted + pass rmtree(tome, True) if GUI: GUI.progressBarTick.emit('tick') @@ -1570,6 +1612,12 @@ def makeBook(source, qtgui=None): end = perf_counter() print(f"makeBook: {end - start} seconds") + # Clean up temporary workspace + try: + rmtree(path, True) + except Exception: + pass + print(filepath) return filepath @@ -1649,3 +1697,4 @@ def makeMOBI(work, qtgui=None): makeMOBIWorkerPool.close() makeMOBIWorkerPool.join() return makeMOBIWorkerOutput + diff --git a/requirements.txt b/requirements.txt index c8ef592..45acf0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,4 @@ mozjpeg-lossless-optimization>=1.2.0 natsort>=8.4.0 distro>=1.8.0 numpy>=1.22.4 -PyMuPDF>=1.26.1 +PyMuPDF>=1.18.0 diff --git a/setup.py b/setup.py index 0b14e7e..1d7bd1c 100644 --- a/setup.py +++ b/setup.py @@ -79,6 +79,7 @@ setuptools.setup( install_requires=[ 'pyside6>=6.5.1', 'Pillow>=11.3.0', + 'PyMuPDF>=1.18.0', 'psutil>=5.9.5', 'python-slugify>=1.2.1,<9.0.0', 'raven>=6.0.0',