mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Edit Book: Add a new tool to compress images in the book losslessly, accessed from the Tools menu
This commit is contained in:
parent
ceed1a1667
commit
30a8f1298b
65
imgsrc/compress-image.svg
Normal file
65
imgsrc/compress-image.svg
Normal 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 |
BIN
resources/images/compress-image.png
Normal file
BIN
resources/images/compress-image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.3 KiB |
@ -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():
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user