mirror of
https://github.com/ciromattia/kcc
synced 2025-12-13 01:36:27 +00:00
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 <alexkurosakimh3@gmail.com>
This commit is contained in:
@@ -12,7 +12,11 @@ like Kindle, Kobo, ReMarkable, and more.
|
|||||||
Pages display in fullscreen without margins,
|
Pages display in fullscreen without margins,
|
||||||
with proper fixed layout support.
|
with proper fixed layout support.
|
||||||
Supported input formats include JPG/PNG/GIF image files in folders, archives, or PDFs.
|
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,
|
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!
|
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
|
-a AUTHOR, --author AUTHOR
|
||||||
Author name [Default=KCC]
|
Author name [Default=KCC]
|
||||||
-f FORMAT, --format FORMAT
|
-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'
|
--nokepub If format is EPUB, output file with '.epub' extension rather than '.kepub.epub'
|
||||||
-b BATCHSPLIT, --batchsplit BATCHSPLIT
|
-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]
|
Split output into multiple files. 0: Don't split 1: Automatic mode 2: Consider every subdirectory as separate volume [Default=0]
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ class WorkerThread(QThread):
|
|||||||
if gui_current_format == 'CBZ':
|
if gui_current_format == 'CBZ':
|
||||||
MW.addMessage.emit('Creating CBZ files', 'info', False)
|
MW.addMessage.emit('Creating CBZ files', 'info', False)
|
||||||
GUI.progress.content = 'Creating CBZ files'
|
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:
|
else:
|
||||||
MW.addMessage.emit('Creating EPUB files', 'info', False)
|
MW.addMessage.emit('Creating EPUB files', 'info', False)
|
||||||
GUI.progress.content = 'Creating EPUB files'
|
GUI.progress.content = 'Creating EPUB files'
|
||||||
@@ -368,6 +371,8 @@ class WorkerThread(QThread):
|
|||||||
GUI.progress.content = ''
|
GUI.progress.content = ''
|
||||||
if gui_current_format == 'CBZ':
|
if gui_current_format == 'CBZ':
|
||||||
MW.addMessage.emit('Creating CBZ files... <b>Done!</b>', 'info', True)
|
MW.addMessage.emit('Creating CBZ files... <b>Done!</b>', 'info', True)
|
||||||
|
elif gui_current_format == 'PDF':
|
||||||
|
MW.addMessage.emit('Creating PDF files... <b>Done!</b>', 'info', True)
|
||||||
else:
|
else:
|
||||||
MW.addMessage.emit('Creating EPUB files... <b>Done!</b>', 'info', True)
|
MW.addMessage.emit('Creating EPUB files... <b>Done!</b>', 'info', True)
|
||||||
if 'MOBI' in gui_current_format:
|
if 'MOBI' in gui_current_format:
|
||||||
@@ -1011,6 +1016,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
|
|||||||
"MOBI/AZW3": {'icon': 'MOBI', 'format': 'MOBI'},
|
"MOBI/AZW3": {'icon': 'MOBI', 'format': 'MOBI'},
|
||||||
"EPUB": {'icon': 'EPUB', 'format': 'EPUB'},
|
"EPUB": {'icon': 'EPUB', 'format': 'EPUB'},
|
||||||
"CBZ": {'icon': 'CBZ', 'format': 'CBZ'},
|
"CBZ": {'icon': 'CBZ', 'format': 'CBZ'},
|
||||||
|
"PDF": {'icon': 'EPUB', 'format': 'PDF'},
|
||||||
"KFX (does not work)": {'icon': 'KFX', 'format': 'KFX'},
|
"KFX (does not work)": {'icon': 'KFX', 'format': 'KFX'},
|
||||||
"MOBI + EPUB": {'icon': 'MOBI', 'format': 'MOBI+EPUB'},
|
"MOBI + EPUB": {'icon': 'MOBI', 'format': 'MOBI+EPUB'},
|
||||||
"EPUB (200MB limit)": {'icon': 'EPUB', 'format': 'EPUB-200MB'},
|
"EPUB (200MB limit)": {'icon': 'EPUB', 'format': 'EPUB-200MB'},
|
||||||
@@ -1092,11 +1098,11 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
|
|||||||
'Label': 'KoS'},
|
'Label': 'KoS'},
|
||||||
"Kobo Elipsa": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': False,
|
"Kobo Elipsa": {'PVOptions': False, 'ForceExpert': False, 'DefaultFormat': 1, 'DefaultUpscale': True, 'ForceColor': False,
|
||||||
'Label': 'KoE'},
|
'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'},
|
'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'},
|
'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'},
|
'Label': 'RmkPP'},
|
||||||
"Other": {'PVOptions': False, 'ForceExpert': True, 'DefaultFormat': 1, 'DefaultUpscale': False, 'ForceColor': False,
|
"Other": {'PVOptions': False, 'ForceExpert': True, 'DefaultFormat': 1, 'DefaultUpscale': False, 'ForceColor': False,
|
||||||
'Label': 'OTHER'},
|
'Label': 'OTHER'},
|
||||||
|
|||||||
@@ -582,6 +582,34 @@ def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, len
|
|||||||
buildOPF(path, options.title, filelist, cover)
|
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):
|
def imgDirectoryProcessing(path):
|
||||||
global workerPool, workerOutput
|
global workerPool, workerOutput
|
||||||
workerPool = Pool(maxtasksperchild=100)
|
workerPool = Pool(maxtasksperchild=100)
|
||||||
@@ -659,6 +687,7 @@ def imgFileProcessing(work):
|
|||||||
pass
|
pass
|
||||||
elif opt.forcepng:
|
elif opt.forcepng:
|
||||||
img.convertToGrayscale()
|
img.convertToGrayscale()
|
||||||
|
if opt.format != 'PDF':
|
||||||
img.quantizeImage()
|
img.quantizeImage()
|
||||||
else:
|
else:
|
||||||
img.convertToGrayscale()
|
img.convertToGrayscale()
|
||||||
@@ -1137,6 +1166,7 @@ def detectSuboptimalProcessing(tmppath, orgpath):
|
|||||||
try:
|
try:
|
||||||
img = Image.open(path)
|
img = Image.open(path)
|
||||||
imageNumber += 1
|
imageNumber += 1
|
||||||
|
# count images smaller than device resolution
|
||||||
if options.profileData[1][0] > img.size[0] and options.profileData[1][1] > img.size[1]:
|
if options.profileData[1][0] > img.size[0] and options.profileData[1][1] > img.size[1]:
|
||||||
imageSmaller += 1
|
imageSmaller += 1
|
||||||
except Exception as err:
|
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))
|
value = sub(r'0*([0-9]{4,})', r'\1', sub(r'([0-9]+)', r'0000\1', value, count=2))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def makeZIP(zipfilename, basedir, isepub=False):
|
def makeZIP(zipfilename, basedir, isepub=False):
|
||||||
start = perf_counter()
|
start = perf_counter()
|
||||||
zipfilename = os.path.abspath(zipfilename) + '.zip'
|
zipfilename = os.path.abspath(zipfilename) + '.zip'
|
||||||
@@ -1209,7 +1238,6 @@ def makeZIP(zipfilename, basedir, isepub=False):
|
|||||||
print(f"makeZIP time: {end - start} seconds")
|
print(f"makeZIP time: {end - start} seconds")
|
||||||
return zipfilename
|
return zipfilename
|
||||||
|
|
||||||
|
|
||||||
def makeParser():
|
def makeParser():
|
||||||
psr = ArgumentParser(prog="kcc-c2e", usage="kcc-c2e [options] [input]", add_help=False)
|
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",
|
output_options.add_argument("-a", "--author", action="store", dest="author", default="defaultauthor",
|
||||||
help="Author name [Default=KCC]")
|
help="Author name [Default=KCC]")
|
||||||
output_options.add_argument("-f", "--format", action="store", dest="format", default="Auto",
|
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]")
|
"[Default=Auto]")
|
||||||
output_options.add_argument("--nokepub", action="store_true", dest="noKepub", default=False,
|
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'")
|
help="If format is EPUB, output file with '.epub' extension rather than '.kepub.epub'")
|
||||||
@@ -1337,6 +1365,8 @@ def checkOptions(options):
|
|||||||
options.format = 'CBZ'
|
options.format = 'CBZ'
|
||||||
elif options.profile in image.ProfileData.ProfilesKindle.keys():
|
elif options.profile in image.ProfileData.ProfilesKindle.keys():
|
||||||
options.format = 'MOBI'
|
options.format = 'MOBI'
|
||||||
|
elif options.profile in image.ProfileData.ProfilesRemarkable.keys():
|
||||||
|
options.format = 'PDF'
|
||||||
else:
|
else:
|
||||||
options.format = 'EPUB'
|
options.format = 'EPUB'
|
||||||
if options.profile in image.ProfileData.ProfilesKindle.keys():
|
if options.profile in image.ProfileData.ProfilesKindle.keys():
|
||||||
@@ -1500,6 +1530,8 @@ def makeBook(source, qtgui=None):
|
|||||||
if GUI:
|
if GUI:
|
||||||
if options.format == 'CBZ':
|
if options.format == 'CBZ':
|
||||||
GUI.progressBarTick.emit('Compressing CBZ files')
|
GUI.progressBarTick.emit('Compressing CBZ files')
|
||||||
|
elif options.format == 'PDF':
|
||||||
|
GUI.progressBarTick.emit('Creating PDF files')
|
||||||
else:
|
else:
|
||||||
GUI.progressBarTick.emit('Compressing EPUB files')
|
GUI.progressBarTick.emit('Compressing EPUB files')
|
||||||
GUI.progressBarTick.emit(str(len(tomes) + 1))
|
GUI.progressBarTick.emit(str(len(tomes) + 1))
|
||||||
@@ -1521,6 +1553,14 @@ def makeBook(source, qtgui=None):
|
|||||||
else:
|
else:
|
||||||
filepath.append(getOutputFilename(source, options.output, '.cbz', ''))
|
filepath.append(getOutputFilename(source, options.output, '.cbz', ''))
|
||||||
makeZIP(tome + '_comic', os.path.join(tome, "OEBPS", "Images"))
|
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:
|
else:
|
||||||
print("Creating EPUB file...")
|
print("Creating EPUB file...")
|
||||||
if len(tomes) > 1:
|
if len(tomes) > 1:
|
||||||
@@ -1530,6 +1570,8 @@ def makeBook(source, qtgui=None):
|
|||||||
buildEPUB(tome, chapterNames, tomeNumber, False, cover)
|
buildEPUB(tome, chapterNames, tomeNumber, False, cover)
|
||||||
filepath.append(getOutputFilename(source, options.output, '.epub', ''))
|
filepath.append(getOutputFilename(source, options.output, '.epub', ''))
|
||||||
makeZIP(tome + '_comic', tome, True)
|
makeZIP(tome + '_comic', tome, True)
|
||||||
|
# Copy files to final destination (PDF files are already saved directly)
|
||||||
|
if options.format != 'PDF':
|
||||||
copyfile(tome + '_comic.zip', filepath[-1])
|
copyfile(tome + '_comic.zip', filepath[-1])
|
||||||
try:
|
try:
|
||||||
os.remove(tome + '_comic.zip')
|
os.remove(tome + '_comic.zip')
|
||||||
@@ -1570,6 +1612,12 @@ def makeBook(source, qtgui=None):
|
|||||||
|
|
||||||
end = perf_counter()
|
end = perf_counter()
|
||||||
print(f"makeBook: {end - start} seconds")
|
print(f"makeBook: {end - start} seconds")
|
||||||
|
# Clean up temporary workspace
|
||||||
|
try:
|
||||||
|
rmtree(path, True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(filepath)
|
||||||
return filepath
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
@@ -1649,3 +1697,4 @@ def makeMOBI(work, qtgui=None):
|
|||||||
makeMOBIWorkerPool.close()
|
makeMOBIWorkerPool.close()
|
||||||
makeMOBIWorkerPool.join()
|
makeMOBIWorkerPool.join()
|
||||||
return makeMOBIWorkerOutput
|
return makeMOBIWorkerOutput
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ mozjpeg-lossless-optimization>=1.2.0
|
|||||||
natsort>=8.4.0
|
natsort>=8.4.0
|
||||||
distro>=1.8.0
|
distro>=1.8.0
|
||||||
numpy>=1.22.4
|
numpy>=1.22.4
|
||||||
PyMuPDF>=1.26.1
|
PyMuPDF>=1.18.0
|
||||||
|
|||||||
1
setup.py
1
setup.py
@@ -79,6 +79,7 @@ setuptools.setup(
|
|||||||
install_requires=[
|
install_requires=[
|
||||||
'pyside6>=6.5.1',
|
'pyside6>=6.5.1',
|
||||||
'Pillow>=11.3.0',
|
'Pillow>=11.3.0',
|
||||||
|
'PyMuPDF>=1.18.0',
|
||||||
'psutil>=5.9.5',
|
'psutil>=5.9.5',
|
||||||
'python-slugify>=1.2.1,<9.0.0',
|
'python-slugify>=1.2.1,<9.0.0',
|
||||||
'raven>=6.0.0',
|
'raven>=6.0.0',
|
||||||
|
|||||||
Reference in New Issue
Block a user