mirror of
https://github.com/crosspoint-reader/calibre-plugins.git
synced 2026-08-13 14:23:41 -07:00
Port the client-side EPUB optimizer from CrossPoint web server to Python. Implements device-specific image optimization (auto-crop, scaling, grayscale conversion, JPEG re-encoding) and EPUB container rewriting (SVG unwrapping, metadata fixes, proper ZIP structure). Supports X3 and X4 device profiles with configurable JPEG quality. Uses Qt's QImage and numpy for image processing with graceful degradation when numpy is unavailable.
190 lines
6.2 KiB
Python
190 lines
6.2 KiB
Python
"""Live optimization progress dialog (shown during transfer).
|
|
|
|
The device driver's ``upload_books`` runs on Calibre's device-manager thread, not
|
|
the GUI thread, and Qt widgets may only be created/shown from the GUI thread.
|
|
|
|
Cross-thread delivery only works reliably when the receiving QObject was *created
|
|
on the GUI thread* (moving a worker-thread object to the GUI thread does not make
|
|
queued delivery work). So the bridge is created once, eagerly, at plugin import
|
|
time — which Calibre performs on the main thread, with the QApplication already
|
|
running — and the worker thread merely emits queued signals to it:
|
|
|
|
begin(title) -> open the dialog and show "working"
|
|
step(tag, message) -> append a line as each image/fix is processed
|
|
finish(payload) -> append the totals and enable the Close button
|
|
|
|
The dialog is modeless, so it appears as soon as optimization starts and updates
|
|
live while books are processed and uploaded.
|
|
"""
|
|
|
|
from qt.core import QObject, QApplication, QThread, Qt, pyqtSignal
|
|
|
|
from .log import add_log
|
|
from .optimizer import _human
|
|
|
|
|
|
def _summary_lines(payload):
|
|
books = payload.get('books', [])
|
|
total_orig = sum(b['orig_size'] for b in books)
|
|
total_new = sum(b['new_size'] for b in books)
|
|
total_imgs = sum(b['images'] for b in books)
|
|
total_fixes = sum(b['fixes'] for b in books)
|
|
total_err = sum(b['errors'] for b in books)
|
|
saved = total_orig - total_new
|
|
pct = (saved / float(total_orig) * 100.0) if total_orig else 0.0
|
|
lines = [
|
|
'',
|
|
'──────────────────────────────',
|
|
'Done: %d book(s), %d image(s), %d fix(es)%s' % (
|
|
len(books), total_imgs, total_fixes,
|
|
(' %d error(s)' % total_err) if total_err else ''),
|
|
'Total size: %s → %s (%+.0f%%)' % (
|
|
_human(total_orig), _human(total_new), -pct),
|
|
]
|
|
return lines
|
|
|
|
|
|
class _ProgressDialog(object):
|
|
"""Wraps the Qt widgets; all methods run on the GUI thread."""
|
|
|
|
def __init__(self, title):
|
|
from qt.core import (
|
|
QDialog, QVBoxLayout, QPlainTextEdit, QLabel, QProgressBar,
|
|
QDialogButtonBox,
|
|
)
|
|
try:
|
|
from calibre.gui2.ui import get_gui
|
|
parent = get_gui()
|
|
except Exception:
|
|
parent = None
|
|
|
|
self.dlg = QDialog(parent)
|
|
self.dlg.setWindowTitle('CrossPoint optimizer')
|
|
self.dlg.setModal(False)
|
|
self.dlg.resize(660, 480)
|
|
layout = QVBoxLayout(self.dlg)
|
|
|
|
self.header = QLabel(title)
|
|
self.header.setWordWrap(True)
|
|
layout.addWidget(self.header)
|
|
|
|
self.bar = QProgressBar(self.dlg)
|
|
self.bar.setRange(0, 0) # indeterminate until finished
|
|
layout.addWidget(self.bar)
|
|
|
|
self.view = QPlainTextEdit(self.dlg)
|
|
self.view.setReadOnly(True)
|
|
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
try:
|
|
from qt.core import QFontDatabase
|
|
self.view.setFont(
|
|
QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
|
except Exception:
|
|
pass
|
|
layout.addWidget(self.view)
|
|
|
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
self.buttons.rejected.connect(self.dlg.reject)
|
|
self.buttons.accepted.connect(self.dlg.accept)
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
|
|
layout.addWidget(self.buttons)
|
|
|
|
def show(self):
|
|
self.dlg.show()
|
|
self.dlg.raise_()
|
|
self.dlg.activateWindow()
|
|
|
|
def append(self, line):
|
|
self.view.appendPlainText(line)
|
|
sb = self.view.verticalScrollBar()
|
|
sb.setValue(sb.maximum())
|
|
|
|
def finish(self, payload):
|
|
from qt.core import QDialogButtonBox
|
|
for line in _summary_lines(payload):
|
|
self.view.appendPlainText(line)
|
|
self.bar.setRange(0, 1)
|
|
self.bar.setValue(1)
|
|
self.header.setText('Optimization complete.')
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
|
|
self.dlg.raise_()
|
|
|
|
|
|
class _Bridge(QObject):
|
|
"""Lives on the GUI thread; drives the dialog when signalled from any thread."""
|
|
|
|
begin_signal = pyqtSignal(object)
|
|
step_signal = pyqtSignal(object)
|
|
finish_signal = pyqtSignal(object)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
q = Qt.ConnectionType.QueuedConnection
|
|
self.begin_signal.connect(self._on_begin, type=q)
|
|
self.step_signal.connect(self._on_step, type=q)
|
|
self.finish_signal.connect(self._on_finish, type=q)
|
|
self._dialog = None
|
|
|
|
def _on_begin(self, title):
|
|
try:
|
|
self._dialog = _ProgressDialog(title)
|
|
self._dialog.show()
|
|
except Exception as exc:
|
|
self._dialog = None
|
|
add_log(f'[CrossPoint] failed to open optimizer dialog: {exc}')
|
|
|
|
def _on_step(self, line):
|
|
if self._dialog is not None:
|
|
try:
|
|
self._dialog.append(line)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_finish(self, payload):
|
|
if self._dialog is not None:
|
|
try:
|
|
self._dialog.finish(payload)
|
|
except Exception as exc:
|
|
add_log(f'[CrossPoint] optimizer dialog finish failed: {exc}')
|
|
|
|
|
|
_bridge = None
|
|
|
|
|
|
def ensure_bridge():
|
|
"""Create the GUI-thread bridge. Must be called on the GUI (main) thread.
|
|
|
|
Called at plugin import (startup, main thread) and again from config_widget()
|
|
as a belt-and-suspenders. No-op off the main thread or without a QApplication.
|
|
"""
|
|
global _bridge
|
|
if _bridge is not None:
|
|
return _bridge
|
|
app = QApplication.instance()
|
|
if app is None:
|
|
return None
|
|
if QThread.currentThread() != app.thread():
|
|
return None # cannot safely create a QObject for the GUI thread here
|
|
_bridge = _Bridge()
|
|
return _bridge
|
|
|
|
|
|
# --- API called from the device-manager (worker) thread ---------------------
|
|
|
|
def begin(title):
|
|
b = _bridge or ensure_bridge()
|
|
if b is not None:
|
|
b.begin_signal.emit(title)
|
|
|
|
|
|
def step(tag, message):
|
|
b = _bridge
|
|
if b is not None:
|
|
b.step_signal.emit('[%s] %s' % (tag, message))
|
|
|
|
|
|
def finish(payload):
|
|
b = _bridge
|
|
if b is not None:
|
|
b.finish_signal.emit(payload)
|