Edit Book: Add a new tool to compress images in the book losslessly, accessed from the Tools menu

This commit is contained in:
Kovid Goyal 2015-11-28 13:08:35 +05:30
parent ceed1a1667
commit 30a8f1298b
6 changed files with 285 additions and 8 deletions

65
imgsrc/compress-image.svg Normal file
View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
x="0px"
y="0px"
viewBox="0 0 256 256"
overflow="visible"
enable-background="new 0 0 76.11 100"
xml:space="preserve"
id="svg2"
inkscape:version="0.91 r13725"
sodipodi:docname="compress-image.svg"
width="256"
height="256"
style="overflow:visible"><metadata
id="metadata16"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs14" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1058"
id="namedview12"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="1.888"
inkscape:cx="-108.13144"
inkscape:cy="37.499"
inkscape:window-x="0"
inkscape:window-y="22"
inkscape:window-maximized="0"
inkscape:current-layer="svg2" /><path
d="m 222.1275,226.88826 c -3.9632,0 -7.44761,1.92415 -9.44305,4.82059 l -28.9242,0 0,-11.26929 c 0,-0.82683 -0.71958,-1.49543 -1.59414,-1.49543 l -2.40782,0 0,-6.35429 8.80374,0 c 1.31185,0 2.40505,-0.99269 2.40505,-2.21762 l 0,-27.53524 c 0,-1.23002 -1.09597,-2.23803 -2.40505,-2.23803 l -8.80374,0 0,-4.85886 2.89492,0 2.41888,-8.18402 3.48718,0 c 1.31184,0 2.40781,-1.0029 2.40781,-2.23293 l 0,-6.86212 c 0,-1.21727 -1.09597,-2.22272 -2.40781,-2.22272 l -38.53332,0 c -1.32844,0 -2.39674,1.00801 -2.39674,2.22272 l 0,6.86212 c 0,1.23003 1.0683,2.23293 2.39674,2.23293 l 3.48441,0 2.40505,8.18402 3.0499,0 0,4.85886 -63.950964,0 c -3.965973,0 -7.192996,-2.99596 -7.192996,-6.65797 l 0,-123.484967 c 0,-3.672216 3.229791,-6.675829 7.192996,-6.675829 l 52.612094,0 0,5.126812 c 0,1.217267 1.0683,2.21762 2.39675,2.21762 l 38.53331,0 c 1.31184,0 2.40781,-1.002905 2.40781,-2.21762 l 0,-6.864671 c 0,-1.227475 -1.09597,-2.220172 -2.40781,-2.220172 l -4.58038,0 C 144.32193,3.4138771 87.923751,0.48937416 87.923751,0.48937416 86.833316,0.43323187 85.737346,0.40516072 84.644144,0.40516072 50.41722,0.40516072 22.677553,26.138745 22.677553,57.892314 l 0,113.004226 c 0,31.74336 27.739667,51.62539 61.966591,51.62539 1.093202,0 2.189172,-0.0255 3.279607,-0.0689 0,0 28.796889,-1.48521 59.702689,-15.62542 l 0,3.54462 c 0,1.22493 1.0683,2.21762 2.39675,2.21762 l 8.93658,0 0,6.35429 -2.54619,0 c -0.88563,0 -1.59414,0.6686 -1.59414,1.49543 l 0,11.26929 -29.05151,0 c -1.99544,-2.89644 -5.48262,-4.82059 -9.44582,-4.82059 -6.18836,0 -11.19495,4.65981 -11.19495,10.38633 0,5.72651 5.00659,10.37611 11.19495,10.37611 3.9632,0 7.44761,-1.92414 9.44582,-4.82568 l 29.05705,0 0,11.2795 c 0,0.83192 0.7085,1.49032 1.59413,1.49032 l 25.74976,0 c 0.8718,0 1.59414,-0.6584 1.59414,-1.49032 l 0,-11.2795 28.9242,0 c 1.99544,2.90154 5.47985,4.82568 9.44306,4.82568 6.18835,0 11.19218,-4.6496 11.19218,-10.37611 0,-5.72652 -5.00383,-10.38633 -11.19495,-10.38633 z"
id="path4"
inkscape:connector-curvature="0"
style="fill:#484537;fill-opacity:1" /><path
d="m 223.19745,54.706661 -123.986485,0 c -4.890824,0 -8.856178,3.643725 -8.856178,8.137856 l 0,81.378573 c 0,4.49413 3.965354,8.13785 8.856178,8.13785 l 123.986485,0 c 4.89083,0 8.85618,-3.64372 8.85618,-8.13785 l 0,-81.378573 c 0,-4.494131 -3.96535,-8.137856 -8.85618,-8.137856 z m 0,89.516429 -123.986485,0 0,-81.378573 123.986485,0 0,81.378573 z"
id="path7"
inkscape:connector-curvature="0"
style="fill:#546e7a" /><path
d="m 125.7795,79.120231 c 4.89525,0 8.85617,3.639656 8.85617,8.137857 0,4.4982 -3.96092,8.137857 -8.85617,8.137857 -4.89525,0 -8.85618,-3.639657 -8.85618,-8.137857 0,-4.498201 3.96093,-8.137857 8.85618,-8.137857 m 0,-8.137857 c -9.76837,0 -17.71236,7.299658 -17.71236,16.275714 0,8.976056 7.94399,16.275712 17.71236,16.275712 9.76836,0 17.71235,-7.299656 17.71235,-16.275712 0,-8.976056 -7.94399,-16.275714 -17.71235,-16.275714 l 0,0 z"
id="path9"
inkscape:connector-curvature="0"
style="fill:#ffa000" /><path
d="m 121.35141,136.08523 c -1.13249,0 -2.26608,-0.39774 -3.13066,-1.1922 -1.73028,-1.58993 -1.73028,-4.16455 0,-5.75346 L 144.78928,104.726 c 1.35832,-1.24001 3.41627,-1.5462 5.11112,-0.76293 l 14.40901,6.62015 24.35006,-29.833383 c 0.85572,-1.056904 2.27493,-1.673347 3.66313,-1.629606 1.44024,0.03967 2.76756,0.711045 3.56351,1.81169 l 17.71235,24.413569 c 1.35832,1.87171 0.80813,4.39444 -1.22769,5.64259 -2.04135,1.23594 -4.79119,0.73953 -6.14065,-1.12811 l -14.24073,-19.62953 -22.81573,27.94947 c -1.27972,1.57773 -3.60668,2.08227 -5.52183,1.20033 l -14.85845,-6.82664 -24.31131,22.33943 c -0.86459,0.79446 -1.99818,1.1922 -3.13066,1.1922 z"
id="path11"
inkscape:connector-curvature="0"
style="fill:#b0bec5" /></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
print_function) print_function)
import os import os
from functools import partial from functools import partial
from threading import Thread from threading import Thread, Event
from Queue import Queue, Empty from Queue import Queue, Empty
from calibre import detect_ncpus, human_readable from calibre import detect_ncpus, human_readable
@ -15,14 +15,16 @@ class Worker(Thread):
daemon = True 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) Thread.__init__(self, name=name)
self.queue, self.results, self.container = queue, results, container self.queue, self.results, self.container = queue, results, container
self.progress_callback = progress_callback
self.jpeg_quality = jpeg_quality self.jpeg_quality = jpeg_quality
self.abort = abort
self.start() self.start()
def run(self): def run(self):
while True: while not self.abort.is_set():
try: try:
name = self.queue.get_nowait() name = self.queue.get_nowait()
except Empty: except Empty:
@ -33,6 +35,11 @@ class Worker(Thread):
import traceback import traceback
self.results[name] = (False, traceback.format_exc()) self.results[name] = (False, traceback.format_exc())
finally: finally:
try:
self.progress_callback(name)
except Exception:
import traceback
traceback.print_exc()
self.queue.task_done() self.queue.task_done()
def compress(self, name): def compress(self, name):
@ -50,19 +57,28 @@ class Worker(Thread):
after = os.path.getsize(path) after = os.path.getsize(path)
self.results[name] = (True, (before, after)) self.results[name] = (True, (before, after))
def get_compressible_images(container):
def compress_images(container, report=None, names=None, jpeg_quality=None):
mt_map = container.manifest_type_map mt_map = container.manifest_type_map
images = set() images = set()
for mt in 'png jpg jpeg'.split(): for mt in 'png jpg jpeg'.split():
images |= set(mt_map.get('image/' + mt, ())) 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: if names is not None:
images &= set(names) images &= set(names)
results = {} results = {}
queue = Queue() queue = Queue()
abort = Event()
for name in images: for name in images:
queue.put(name) 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() queue.join()
before_total = after_total = 0 before_total = after_total = 0
for name, (ok, res) in results.iteritems(): for name, (ok, res) in results.iteritems():

View File

@ -1211,6 +1211,29 @@ class Boss(QObject):
if self.ensure_book(_('You must first open a book in order to check links.')): if self.ensure_book(_('You must first open a book in order to check links.')):
self.gui.check_external_links.show() 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): def sync_editor_to_preview(self, name, sourceline_address):
editor = self.edit_file(name, 'html') editor = self.edit_file(name, 'html')
self.ignore_preview_to_editor_sync = True self.ignore_preview_to_editor_sync = True

View File

@ -6,13 +6,20 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
from threading import Thread
from PyQt5.Qt import ( from PyQt5.Qt import (
QTextBrowser, QVBoxLayout, QDialog, QDialogButtonBox, QIcon, QLabel, 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.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): class Abort(Exception):
pass pass
@ -70,3 +77,166 @@ def show_report(changed, title, report, parent, show_current_diff):
d.bb.accepted.connect(d.accept) d.bb.accepted.connect(d.accept)
d.resize(600, 400) d.resize(600, 400)
d.exec_() 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

View File

@ -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')) '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', (), _( 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')) '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): def ereg(icon, text, target, sid, keys, description):
return reg(icon, text, partial(self.boss.editor_action, 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_manage_fonts)
e.addAction(self.action_embed_fonts) e.addAction(self.action_embed_fonts)
e.addAction(self.action_subset_fonts) e.addAction(self.action_subset_fonts)
e.addAction(self.action_compress_images)
e.addAction(self.action_smarten_punctuation) e.addAction(self.action_smarten_punctuation)
e.addAction(self.action_remove_unused_css) e.addAction(self.action_remove_unused_css)
e.addAction(self.action_fix_html_all) e.addAction(self.action_fix_html_all)