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)
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():

View File

@ -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

View File

@ -6,13 +6,20 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
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

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'))
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)