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. :param sort_on_mtime: If True sort pages based on their last modified time.
Otherwise, sort alphabetically. 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 items = generate_entries_from_dir(dir_or_items) if isinstance(dir_or_items, str) else dir_or_items
sep_counts = set() sep_counts = set()
pages = [] pages = []
@ -65,8 +65,8 @@ def find_pages(dir_or_items, sort_on_mtime=False, verbose=False):
if '__MACOSX' in path: if '__MACOSX' in path:
continue continue
ext = path.rpartition('.')[2].lower() ext = path.rpartition('.')[2].lower()
if ext in extensions: if ext in comic_exts:
sep_counts.add(path.replace(os.sep, '/').count('/')) sep_counts.add(path.replace('\\', '/').count('/'))
pages.append(path) pages.append(path)
# Use the full path to sort unless the files are in folders of different # Use the full path to sort unless the files are in folders of different
# levels, in which case simply use the filenames. # 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) comment = get_comment(stream)
return parse_comic_comment(comment or b'{}', series_index=series_index) 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() fmt = fmt.lower()
cdata = None cdata = None
db = self.gui.current_db.new_api db = self.gui.current_db.new_api
if fmt == 'pdf': if fmt in ('pdf', 'cbz', 'cbr'):
pdfpath = db.format_abspath(book_id, fmt) path = db.format_abspath(book_id, fmt)
if pdfpath is None: if path is None:
return error_dialog(self.gui, _('Format file missing'), _( 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 from calibre.gui2.metadata.pdf_covers import PDFCovers
d = PDFCovers(pdfpath, parent=self.gui) d = PDFCovers(path, parent=self.gui)
ret = d.exec() ret = d.exec()
if ret == QDialog.DialogCode.Accepted: if ret == QDialog.DialogCode.Accepted:
cpath = d.cover_path cpath = d.cover_path

View File

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

View File

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

View File

@ -94,6 +94,12 @@ def extract_member(
return name, data 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): def extract_first_alphabetically(stream):
from calibre.libunzip import sort_key from calibre.libunzip import sort_key
names_ = sorted(( names_ = sorted((