diff --git a/imgsrc/compress-image.svg b/imgsrc/compress-image.svg new file mode 100644 index 0000000000..932092bd2a --- /dev/null +++ b/imgsrc/compress-image.svg @@ -0,0 +1,65 @@ + +image/svg+xml \ No newline at end of file diff --git a/resources/images/compress-image.png b/resources/images/compress-image.png new file mode 100644 index 0000000000..2ccca44026 Binary files /dev/null and b/resources/images/compress-image.png differ diff --git a/src/calibre/ebooks/oeb/polish/images.py b/src/calibre/ebooks/oeb/polish/images.py index 766a6b717b..31a203f2c0 100644 --- a/src/calibre/ebooks/oeb/polish/images.py +++ b/src/calibre/ebooks/oeb/polish/images.py @@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) import os from functools import partial -from threading import Thread +from threading import Thread, Event from Queue import Queue, Empty from calibre import detect_ncpus, human_readable @@ -15,14 +15,16 @@ class Worker(Thread): daemon = True - def __init__(self, name, queue, results, container, jpeg_quality): + def __init__(self, abort, name, queue, results, container, jpeg_quality, progress_callback): Thread.__init__(self, name=name) self.queue, self.results, self.container = queue, results, container + self.progress_callback = progress_callback self.jpeg_quality = jpeg_quality + self.abort = abort self.start() def run(self): - while True: + while not self.abort.is_set(): try: name = self.queue.get_nowait() except Empty: @@ -33,6 +35,11 @@ class Worker(Thread): import traceback self.results[name] = (False, traceback.format_exc()) finally: + try: + self.progress_callback(name) + except Exception: + import traceback + traceback.print_exc() self.queue.task_done() def compress(self, name): @@ -50,19 +57,28 @@ class Worker(Thread): after = os.path.getsize(path) self.results[name] = (True, (before, after)) - -def compress_images(container, report=None, names=None, jpeg_quality=None): +def get_compressible_images(container): mt_map = container.manifest_type_map images = set() for mt in 'png jpg jpeg'.split(): images |= set(mt_map.get('image/' + mt, ())) + return images + +def compress_images(container, report=None, names=None, jpeg_quality=None, progress_callback=lambda n, t, name:True): + images = get_compressible_images(container) if names is not None: images &= set(names) results = {} queue = Queue() + abort = Event() for name in images: queue.put(name) - [Worker('CompressImage%d' % i, queue, results, container, jpeg_quality) for i in xrange(min(detect_ncpus(), len(images)))] + def pc(name): + keep_going = progress_callback(len(results), len(images), name) + if not keep_going: + abort.set() + progress_callback(0, len(images), '') + [Worker(abort, 'CompressImage%d' % i, queue, results, container, jpeg_quality, pc) for i in xrange(min(detect_ncpus(), len(images)))] queue.join() before_total = after_total = 0 for name, (ok, res) in results.iteritems(): diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index 2ff7417f5f..a6b53dbd24 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -1211,6 +1211,29 @@ class Boss(QObject): if self.ensure_book(_('You must first open a book in order to check links.')): self.gui.check_external_links.show() + def compress_images(self): + if not self.ensure_book(_('You must first open a book in order to compress images.')): + return + from calibre.gui2.tweak_book.polish import show_report, CompressImages, CompressImagesProgress + d = CompressImages(self.gui) + if d.exec_() == d.Accepted: + with BusyCursor(): + self.add_savepoint(_('Before: compress images')) + d = CompressImagesProgress(names=d.names, jpeg_quality=d.jpeg_quality, parent=self.gui) + if d.exec_() != d.Accepted: + self.rewind_savepoint() + return + changed, report = d.result + if changed is None and report: + self.rewind_savepoint() + return error_dialog(self.gui, _('Unexpected error'), _( + 'Failed to compress images, click "Show details" for more information'), det_msg=report, show=True) + if changed: + self.apply_container_update_to_gui() + else: + self.rewind_savepoint() + show_report(changed, self.current_metadata.title, report, self.gui, self.show_current_diff) + def sync_editor_to_preview(self, name, sourceline_address): editor = self.edit_file(name, 'html') self.ignore_preview_to_editor_sync = True diff --git a/src/calibre/gui2/tweak_book/polish.py b/src/calibre/gui2/tweak_book/polish.py index f6a65bda75..4e5f86a5e7 100644 --- a/src/calibre/gui2/tweak_book/polish.py +++ b/src/calibre/gui2/tweak_book/polish.py @@ -6,13 +6,20 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' +from threading import Thread from PyQt5.Qt import ( QTextBrowser, QVBoxLayout, QDialog, QDialogButtonBox, QIcon, QLabel, - QCheckBox, Qt) + QCheckBox, Qt, QListWidgetItem, QHBoxLayout, QListWidget, QPixmap, + QSpinBox, QStyledItemDelegate, QSize, QModelIndex, QStyle, QPen, + QProgressBar, pyqtSignal +) +from calibre import human_readable, fit_image from calibre.ebooks.oeb.polish.main import CUSTOMIZATION -from calibre.gui2.tweak_book import tprefs +from calibre.gui2.tweak_book import tprefs, current_container, set_current_container +from calibre.gui2.tweak_book.widgets import Dialog +from calibre.utils.icu import numeric_sort_key class Abort(Exception): pass @@ -70,3 +77,166 @@ def show_report(changed, title, report, parent, show_current_diff): d.bb.accepted.connect(d.accept) d.resize(600, 400) d.exec_() + +# CompressImages {{{ + +class ImageItemDelegate(QStyledItemDelegate): + + def sizeHint(self, option, index): + return QSize(300, 100) + + def paint(self, painter, option, index): + name = index.data(Qt.DisplayRole) + sz = human_readable(index.data(Qt.UserRole)) + pmap = index.data(Qt.UserRole+1) + irect = option.rect.adjusted(0, 5, 0, -5) + irect.setRight(irect.left() + 70) + if pmap is None: + pmap = QPixmap(current_container().get_file_path_for_processing(name)) + scaled, nwidth, nheight = fit_image(pmap.width(), pmap.height(), irect.width(), irect.height()) + if scaled: + pmap = pmap.scaled(nwidth, nheight, transformMode=Qt.SmoothTransformation) + index.model().setData(index, pmap, Qt.UserRole+1) + x, y = (irect.width() - pmap.width())//2, (irect.height() - pmap.height())//2 + r = irect.adjusted(x, y, -x, -y) + QStyledItemDelegate.paint(self, painter, option, QModelIndex()) + painter.drawPixmap(r, pmap) + trect = irect.adjusted(irect.width() + 10, 0, 0, 0) + trect.setRight(option.rect.right()) + painter.save() + if option.state & QStyle.State_Selected: + painter.setPen(QPen(option.palette.color(option.palette.HighlightedText))) + painter.drawText(trect, Qt.AlignVCenter | Qt.AlignLeft, name + '\n' + sz) + painter.restore() + +class CompressImages(Dialog): + + def __init__(self, parent=None): + Dialog.__init__(self, _('Compress Images'), 'compress-images', parent=parent) + + def setup_ui(self): + from calibre.ebooks.oeb.polish.images import get_compressible_images + self.setWindowIcon(QIcon(I('compress-image.png'))) + self.h = h = QHBoxLayout(self) + self.images = i = QListWidget(self) + h.addWidget(i) + self.l = l = QVBoxLayout() + h.addLayout(l) + c = current_container() + for name in sorted(get_compressible_images(c), key=numeric_sort_key): + x = QListWidgetItem(name, i) + x.setData(Qt.UserRole, c.filesize(name)) + i.setSelectionMode(i.ExtendedSelection) + i.setMinimumHeight(500), i.setMinimumWidth(350) + i.selectAll(), i.setSpacing(5) + self.delegate = ImageItemDelegate(self) + i.setItemDelegate(self.delegate) + self.la = la = QLabel(_( + 'You can compress the images in this book losslessly, reducing the file size of the book,' + ' without affecting image quality. Typically image size is reduced by 5 - 15%.')) + la.setWordWrap(True) + la.setMinimumWidth(250) + l.addWidget(la), l.addSpacing(30) + + self.enable_lossy = el = QCheckBox(_('Enable &lossy compression of JPEG images')) + el.setToolTip(_('This allows you to change the quality factor used for JPEG images.\nBy lowering' + ' the quality you can greatly reduce file size, at the expense of the image looking blurred.')) + l.addWidget(el) + self.h2 = h = QHBoxLayout() + l.addLayout(h) + self.jq = jq = QSpinBox(self) + jq.setMinimum(0), jq.setMaximum(100), jq.setValue(80), jq.setEnabled(False) + jq.setToolTip(_('The compression quality, 1 is high compression, 100 is low compression.\nImage' + ' quality is inversely correlated with compression quality.')) + el.toggled.connect(jq.setEnabled) + self.jql = la = QLabel(_('Compression &quality:')) + la.setBuddy(jq) + h.addWidget(la), h.addWidget(jq) + l.addStretch(10) + l.addWidget(self.bb) + + @property + def names(self): + return {item.text() for item in self.images.selectedItems()} + + @property + def jpeg_quality(self): + if not self.enable_lossy.isChecked(): + return None + return self.jq.value() + +class CompressImagesProgress(Dialog): + + gui_loop = pyqtSignal(object, object, object) + cidone = pyqtSignal() + + def __init__(self, names=None, jpeg_quality=None, parent=None): + self.names, self.jpeg_quality = names, jpeg_quality + self.keep_going = True + self.result = (None, '') + Dialog.__init__(self, _('Compressing Images...'), 'compress-images-progress', parent=parent) + self.gui_loop.connect(self.update_progress, type=Qt.QueuedConnection) + self.cidone.connect(self.accept, type=Qt.QueuedConnection) + t = Thread(name='RunCompressImages', target=self.run_compress) + t.daemon = True + t.start() + + def run_compress(self): + from calibre.gui2.tweak_book import current_container + from calibre.ebooks.oeb.polish.images import compress_images + report = [] + try: + self.result = (compress_images( + current_container(), report=report.append, names=self.names, jpeg_quality=self.jpeg_quality, + progress_callback=self.progress_callback + )[0], report) + except Exception: + import traceback + self.result = (None, traceback.format_exc()) + self.cidone.emit() + + def setup_ui(self): + self.setWindowIcon(QIcon(I('compress-image.png'))) + self.setCursor(Qt.BusyCursor) + self.setMinimumWidth(350) + self.l = l = QVBoxLayout(self) + self.la = la = QLabel(_('Compressing images, please wait...')) + la.setStyleSheet('QLabel { font-weight: bold }'), la.setAlignment(Qt.AlignCenter), la.setTextFormat(Qt.PlainText) + l.addWidget(la) + self.progress = p = QProgressBar(self) + p.setMinimum(0), p.setMaximum(0) + l.addWidget(p) + self.msg = la = QLabel('\xa0') + la.setAlignment(Qt.AlignCenter), la.setTextFormat(Qt.PlainText) + l.addWidget(la) + + self.bb.setStandardButtons(self.bb.Cancel) + l.addWidget(self.bb) + + def reject(self): + self.keep_going = False + self.bb.button(self.bb.Cancel).setEnabled(False) + Dialog.reject(self) + + def progress_callback(self, num, total, name): + self.gui_loop.emit(num, total, name) + return self.keep_going + + def update_progress(self, num, total, name): + self.progress.setMaximum(total), self.progress.setValue(num) + self.msg.setText(name) + +# }}} + +if __name__ == '__main__': + from calibre.gui2 import Application + app = Application([]) + import sys, sip + from calibre.ebooks.oeb.polish.container import get_container + c = get_container(sys.argv[-1], tweak_mode=True) + set_current_container(c) + d = CompressImages() + if d.exec_() == d.Accepted: + pass + sip.delete(app) + del app diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index d53dd9e7dd..15d2339351 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -390,6 +390,8 @@ class Main(MainWindow): 'reports.png', _('&Reports'), self.boss.show_reports, 'show-reports', ('Ctrl+Shift+R',), _('Show a report on various aspects of the book')) self.action_check_external_links = treg('insert-link.png', _('Check &external links'), self.boss.check_external_links, 'check-external-links', (), _( 'Check external links in the book')) + self.action_compress_images = treg('compress-image.png', _('Compress &images losslessly'), self.boss.compress_images, 'compress-images', (), _( + 'Compress images losslessly')) def ereg(icon, text, target, sid, keys, description): return reg(icon, text, partial(self.boss.editor_action, target), sid, keys, description) @@ -538,6 +540,7 @@ class Main(MainWindow): e.addAction(self.action_manage_fonts) e.addAction(self.action_embed_fonts) e.addAction(self.action_subset_fonts) + e.addAction(self.action_compress_images) e.addAction(self.action_smarten_punctuation) e.addAction(self.action_remove_unused_css) e.addAction(self.action_fix_html_all)