Edit metadata: When setting a cover from comic files allow choosing which page to use as the cover. Fixes #2007765 [Set cover from format: Image selection for comic files](https://bugs.launchpad.net/calibre/+bug/2007765)

This commit is contained in:
Kovid Goyal 2023-03-09 14:58:37 +05:30
parent a86b88c634
commit 7e8dc39ff8
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 108 additions and 38 deletions

View File

@ -57,7 +57,7 @@ def find_pages(dir_or_items, sort_on_mtime=False, verbose=False):
:param sort_on_mtime: If True sort pages based on their last modified time.
Otherwise, sort alphabetically.
'''
extensions = {'jpeg', 'jpg', 'gif', 'png', 'webp'}
from calibre.libunzip import comic_exts
items = generate_entries_from_dir(dir_or_items) if isinstance(dir_or_items, str) else dir_or_items
sep_counts = set()
pages = []
@ -65,8 +65,8 @@ def find_pages(dir_or_items, sort_on_mtime=False, verbose=False):
if '__MACOSX' in path:
continue
ext = path.rpartition('.')[2].lower()
if ext in extensions:
sep_counts.add(path.replace(os.sep, '/').count('/'))
if ext in comic_exts:
sep_counts.add(path.replace('\\', '/').count('/'))
pages.append(path)
# Use the full path to sort unless the files are in folders of different
# levels, in which case simply use the filenames.

View File

@ -199,3 +199,60 @@ def get_comic_metadata(stream, stream_type, series_index='volume'):
comment = get_comment(stream)
return parse_comic_comment(comment or b'{}', series_index=series_index)
def get_comic_images(path, tdir, first=1, last=0): # first and last use 1 based indexing
from functools import partial
with open(path, 'rb') as f:
fmt = archive_type(f)
if fmt not in ('zip', 'rar'):
return 0
items = {}
if fmt == 'rar':
from calibre.utils.unrar import headers
for h in headers(path):
items[h['filename']] = lambda : partial(h.get, 'file_time', 0)
else:
from zipfile import ZipFile
with ZipFile(path) as zf:
for i in zf.infolist():
items[i.filename] = partial(getattr, i, 'date_time')
from calibre.ebooks.comic.input import find_pages
pages = find_pages(items)
if last <= 0:
last = len(pages)
pages = pages[first-1:last]
def make_filename(num, ext):
return f'{num:08d}{ext}'
if fmt == 'rar':
all_pages = {p:i+first for i, p in enumerate(pages)}
from calibre.utils.unrar import extract_members
current = None
def callback(x):
nonlocal current
if isinstance(x, dict):
if current is not None:
current.close()
fname = x['filename']
if fname in all_pages:
ext = os.path.splitext(fname)[1]
num = all_pages[fname]
current = open(os.path.join(tdir, make_filename(num, ext)), 'wb')
return True
return False
if isinstance(x, bytes):
current.write(x)
extract_members(path, callback)
if current is not None:
current.close()
else:
import shutil
with ZipFile(path) as zf:
for i, name in enumerate(pages):
num = i + first
ext = os.path.splitext(name)[1]
with open(os.path.join(tdir, make_filename(num, ext)), 'wb') as dest, zf.open(name) as src:
shutil.copyfileobj(src, dest)
return len(pages)

View File

@ -983,13 +983,13 @@ class EditMetadataAction(InterfaceAction):
fmt = fmt.lower()
cdata = None
db = self.gui.current_db.new_api
if fmt == 'pdf':
pdfpath = db.format_abspath(book_id, fmt)
if pdfpath is None:
if fmt in ('pdf', 'cbz', 'cbr'):
path = db.format_abspath(book_id, fmt)
if path is None:
return error_dialog(self.gui, _('Format file missing'), _(
'Cannot read cover as the %s file is missing from this book') % 'PDF', show=True)
'Cannot read cover as the %s file is missing from this book') % fmt.upper(), show=True)
from calibre.gui2.metadata.pdf_covers import PDFCovers
d = PDFCovers(pdfpath, parent=self.gui)
d = PDFCovers(path, parent=self.gui)
ret = d.exec()
if ret == QDialog.DialogCode.Accepted:
cpath = d.cover_path

View File

@ -5,20 +5,23 @@ __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import sys, shutil, os
from threading import Thread
from glob import glob
import os
import shutil
import sys
from qt.core import (
QDialog, QApplication, QLabel, QVBoxLayout, QDialogButtonBox, Qt, QAbstractItemView, QListView,
pyqtSignal, QListWidget, QListWidgetItem, QSize, QPixmap, QStyledItemDelegate, sip
QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel, QListView,
QListWidget, QListWidgetItem, QPixmap, QSize, QStyledItemDelegate, Qt, QTimer,
QVBoxLayout, pyqtSignal, sip,
)
from threading import Thread
from calibre import as_unicode
from calibre.ebooks.metadata.archive import get_comic_images
from calibre.ebooks.metadata.pdf import page_images
from calibre.gui2 import error_dialog, file_icon_provider
from calibre.ptempfile import PersistentTemporaryDirectory
from calibre.gui2.progress_indicator import WaitLayout
from calibre.libunzip import comic_exts
from calibre.ptempfile import PersistentTemporaryDirectory
class CoverDelegate(QStyledItemDelegate):
@ -42,11 +45,13 @@ class PDFCovers(QDialog):
def __init__(self, pdfpath, parent=None):
QDialog.__init__(self, parent)
self.pdfpath = pdfpath
self.stack = WaitLayout(_('Rendering PDF pages, please wait...'), parent=self)
self.ext = os.path.splitext(pdfpath)[1][1:].lower()
self.is_pdf = self.ext == 'pdf'
self.stack = WaitLayout(_('Rendering {} pages, please wait...').format('PDF' if self.is_pdf else _('comic book')), parent=self)
self.container = self.stack.after
self.container.l = l = QVBoxLayout(self.container)
self.la = la = QLabel(_('Choose a cover from the list of PDF pages below'))
self.la = la = QLabel(_('Choose a cover from the list of pages below'))
l.addWidget(la)
self.covers = c = QListWidget(self)
l.addWidget(c)
@ -67,16 +72,15 @@ class PDFCovers(QDialog):
l.addWidget(bb)
self.rendering_done.connect(self.show_pages, type=Qt.ConnectionType.QueuedConnection)
self.first = 1
self.setWindowTitle(_('Choose cover from PDF'))
self.setWindowIcon(file_icon_provider().icon_from_ext('pdf'))
self.setWindowTitle(_('Choose cover from book'))
self.setWindowIcon(file_icon_provider().icon_from_ext(self.ext))
self.resize(QSize(800, 600))
self.tdir = PersistentTemporaryDirectory('_pdf_covers')
self.start_rendering()
QTimer.singleShot(0, self.start_rendering)
def start_rendering(self):
self.hide_pages()
self.thread = Thread(target=self.render)
self.thread.daemon = True
self.thread = Thread(target=self.render, daemon=True, name='RenderPages')
self.thread.start()
@property
@ -97,11 +101,14 @@ class PDFCovers(QDialog):
self.error = None
try:
os.mkdir(self.current_tdir)
page_images(self.pdfpath, self.current_tdir, first=self.first, last=self.first + PAGES_PER_RENDER - 1)
except Exception as e:
if self.covers.count():
pass
if self.is_pdf:
page_images(self.pdfpath, self.current_tdir, first=self.first, last=self.first + PAGES_PER_RENDER - 1)
else:
get_comic_images(self.pdfpath, self.current_tdir, first=self.first, last=self.first + PAGES_PER_RENDER - 1)
except Exception as e:
import traceback
traceback.print_exc()
if not self.covers.count():
self.error = as_unicode(e)
if not sip.isdeleted(self) and self.isVisible():
self.rendering_done.emit()
@ -113,14 +120,14 @@ class PDFCovers(QDialog):
def show_pages(self):
if self.error is not None:
error_dialog(self, _('Failed to render'),
_('Could not render this PDF file'), show=True, det_msg=self.error)
_('Could not render this file'), show=True, det_msg=self.error)
self.reject()
return
self.stack.stop()
files = glob(os.path.join(self.current_tdir, '*.jpg')) + glob(os.path.join(self.current_tdir, '*.jpeg'))
files = tuple(x for x in os.listdir(self.current_tdir) if os.path.splitext(x)[1][1:].lower() in comic_exts)
if not files and not self.covers.count():
error_dialog(self, _('Failed to render'),
_('This PDF has no pages'), show=True)
_('This book has no pages'), show=True)
self.reject()
return
@ -130,13 +137,14 @@ class PDFCovers(QDialog):
dpr = self.devicePixelRatio()
for i, f in enumerate(sorted(files)):
p = QPixmap(f).scaled(
path = os.path.join(self.current_tdir, f)
p = QPixmap(path).scaled(
self.covers.iconSize()*dpr, aspectRatioMode=Qt.AspectRatioMode.IgnoreAspectRatio,
transformMode=Qt.TransformationMode.SmoothTransformation)
p.setDevicePixelRatio(dpr)
i = QListWidgetItem(_('page %d') % (self.first + i))
i.setData(Qt.ItemDataRole.DecorationRole, p)
i.setData(Qt.ItemDataRole.UserRole, f)
i.setData(Qt.ItemDataRole.UserRole, path)
self.covers.addItem(i)
self.first += len(files)
if len(files) == PAGES_PER_RENDER:

View File

@ -415,24 +415,23 @@ class MetadataSingleDialogBase(QDialog):
if mi is not None:
self.update_from_mi(mi)
def get_pdf_cover(self):
pdfpath = self.formats_manager.get_format_path(self.db, self.book_id,
'pdf')
def choose_cover_from_pages(self, ext):
path = self.formats_manager.get_format_path(self.db, self.book_id, ext.lower())
from calibre.gui2.metadata.pdf_covers import PDFCovers
d = PDFCovers(pdfpath, parent=self)
d = PDFCovers(path, parent=self)
if d.exec() == QDialog.DialogCode.Accepted:
cpath = d.cover_path
if cpath:
with open(cpath, 'rb') as f:
self.update_cover(f.read(), 'PDF')
self.update_cover(f.read(), ext.upper())
d.cleanup()
def cover_from_format(self, *args):
ext = self.formats_manager.get_selected_format()
if ext is None:
return
if ext == 'pdf':
return self.get_pdf_cover()
if ext in ('pdf', 'cbz', 'cbr'):
return self.choose_cover_from_pages(ext)
try:
mi, ext = self.formats_manager.get_selected_format_metadata(self.db,
self.book_id)

View File

@ -94,6 +94,12 @@ def extract_member(
return name, data
def extract_members(path_or_stream, callback):
from unrardll import extract_members
with StreamAsPath(path_or_stream) as path:
extract_members(path, callback)
def extract_first_alphabetically(stream):
from calibre.libunzip import sort_key
names_ = sorted((