From 7e8dc39ff88724e9e258df554e45b171724717b5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 9 Mar 2023 14:58:37 +0530 Subject: [PATCH] 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) --- src/calibre/ebooks/comic/input.py | 6 +-- src/calibre/ebooks/metadata/archive.py | 57 +++++++++++++++++++++++ src/calibre/gui2/actions/edit_metadata.py | 10 ++-- src/calibre/gui2/metadata/pdf_covers.py | 54 ++++++++++++--------- src/calibre/gui2/metadata/single.py | 13 +++--- src/calibre/utils/unrar.py | 6 +++ 6 files changed, 108 insertions(+), 38 deletions(-) diff --git a/src/calibre/ebooks/comic/input.py b/src/calibre/ebooks/comic/input.py index 878997aa6c..2c3b70709f 100644 --- a/src/calibre/ebooks/comic/input.py +++ b/src/calibre/ebooks/comic/input.py @@ -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. diff --git a/src/calibre/ebooks/metadata/archive.py b/src/calibre/ebooks/metadata/archive.py index 37bc601393..b73754a7ae 100644 --- a/src/calibre/ebooks/metadata/archive.py +++ b/src/calibre/ebooks/metadata/archive.py @@ -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) diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py index a2bdd1fa69..64885a1dc9 100644 --- a/src/calibre/gui2/actions/edit_metadata.py +++ b/src/calibre/gui2/actions/edit_metadata.py @@ -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 diff --git a/src/calibre/gui2/metadata/pdf_covers.py b/src/calibre/gui2/metadata/pdf_covers.py index c4a95bbffa..3c6078734a 100644 --- a/src/calibre/gui2/metadata/pdf_covers.py +++ b/src/calibre/gui2/metadata/pdf_covers.py @@ -5,20 +5,23 @@ __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' __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: diff --git a/src/calibre/gui2/metadata/single.py b/src/calibre/gui2/metadata/single.py index d8cb7334c6..041c5c883c 100644 --- a/src/calibre/gui2/metadata/single.py +++ b/src/calibre/gui2/metadata/single.py @@ -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) diff --git a/src/calibre/utils/unrar.py b/src/calibre/utils/unrar.py index 87a96d1fbd..ed5bc8e033 100644 --- a/src/calibre/utils/unrar.py +++ b/src/calibre/utils/unrar.py @@ -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((