mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Implement the images report
This commit is contained in:
parent
c7b434dd07
commit
484bfaed72
@ -55,7 +55,8 @@ def file_data(container):
|
||||
yield File(name, posixpath.dirname(name), posixpath.basename(name), safe_size(container, name),
|
||||
get_category(name, container.mime_map.get(name, '')))
|
||||
|
||||
Image = namedtuple('Image', 'name mime_type usage size width height')
|
||||
Image = namedtuple('Image', 'name mime_type usage size basename width height')
|
||||
|
||||
L = namedtuple('Location', 'name line_number offset word')
|
||||
def Location(name, line_number=None, offset=0, word=None):
|
||||
return L(name, line_number, offset, word)
|
||||
@ -81,7 +82,7 @@ def link_data(container):
|
||||
for name, mt in container.mime_map.iteritems():
|
||||
if mt.startswith('image/') and container.exists(name):
|
||||
image_data.append(Image(name, mt, sort_locations(image_usage.get(name, set())), safe_size(container, name),
|
||||
*safe_img_data(container, name, mt)))
|
||||
posixpath.basename(name), *safe_img_data(container, name, mt)))
|
||||
return tuple(image_data)
|
||||
|
||||
def gather_data(container):
|
||||
|
@ -130,6 +130,8 @@ class Boss(QObject):
|
||||
self.gui.manage_fonts.embed_all_fonts.connect(self.manage_fonts_embed)
|
||||
self.gui.manage_fonts.subset_all_fonts.connect(self.manage_fonts_subset)
|
||||
self.gui.reports.edit_requested.connect(self.reports_edit_requested)
|
||||
self.gui.reports.refresh_starting.connect(self.commit_all_editors_to_container)
|
||||
self.gui.reports.delete_requested.connect(self.delete_requested)
|
||||
|
||||
@property
|
||||
def currently_editing(self):
|
||||
|
@ -9,20 +9,24 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
from threading import Thread
|
||||
from future_builtins import map
|
||||
from operator import itemgetter
|
||||
from functools import partial
|
||||
|
||||
from PyQt5.Qt import (
|
||||
QSize, QStackedLayout, QLabel, QVBoxLayout, Qt, QWidget, pyqtSignal,
|
||||
QAbstractTableModel, QTableView, QSortFilterProxyModel, QIcon, QListWidget,
|
||||
QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray)
|
||||
QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray, QPixmap,
|
||||
QStyledItemDelegate, QModelIndex, QRect, QStyle, QPalette, QTimer, QMenu)
|
||||
|
||||
from calibre import human_readable
|
||||
from calibre import human_readable, fit_image
|
||||
from calibre.ebooks.oeb.polish.report import gather_data, Location
|
||||
from calibre.gui2 import error_dialog
|
||||
from calibre.gui2 import error_dialog, question_dialog
|
||||
from calibre.gui2.tweak_book import current_container, tprefs
|
||||
from calibre.gui2.tweak_book.widgets import Dialog
|
||||
from calibre.gui2.progress_indicator import ProgressIndicator
|
||||
from calibre.utils.icu import primary_contains, numeric_sort_key
|
||||
|
||||
# Utils {{{
|
||||
|
||||
def read_state(name, default=None):
|
||||
data = tprefs.get('reports-ui-state')
|
||||
if data is None:
|
||||
@ -37,7 +41,7 @@ def save_state(name, val):
|
||||
tprefs['reports-ui-state'] = data = {}
|
||||
data[name] = val
|
||||
|
||||
class ProxyModel(QSortFilterProxyModel): # {{{
|
||||
class ProxyModel(QSortFilterProxyModel):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QSortFilterProxyModel.__init__(self, parent)
|
||||
@ -64,7 +68,6 @@ class ProxyModel(QSortFilterProxyModel): # {{{
|
||||
if orientation == Qt.Vertical and role == Qt.DisplayRole:
|
||||
return section + 1
|
||||
return QSortFilterProxyModel.headerData(self, section, orientation, role)
|
||||
# }}}
|
||||
|
||||
class FileCollection(QAbstractTableModel):
|
||||
|
||||
@ -101,7 +104,68 @@ class FileCollection(QAbstractTableModel):
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
class FilesView(QTableView):
|
||||
|
||||
double_clicked = pyqtSignal(object)
|
||||
delete_requested = pyqtSignal(object, object)
|
||||
|
||||
def __init__(self, model, parent=None):
|
||||
QTableView.__init__(self, parent)
|
||||
self.setSelectionBehavior(self.SelectRows), self.setSelectionMode(self.ExtendedSelection)
|
||||
self.setSortingEnabled(True)
|
||||
self.proxy = p = ProxyModel(self)
|
||||
p.setSourceModel(model)
|
||||
self.setModel(p)
|
||||
self.doubleClicked.connect(self._double_clicked)
|
||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.show_context_menu)
|
||||
|
||||
def customize_context_menu(self, menu, selected_locations, current_location):
|
||||
pass
|
||||
|
||||
def _double_clicked(self, index):
|
||||
index = self.proxy.mapToSource(index)
|
||||
if index.isValid():
|
||||
self.double_clicked.emit(index)
|
||||
|
||||
def keyPressEvent(self, ev):
|
||||
if ev.key() == Qt.Key_Delete:
|
||||
self.delete_selected()
|
||||
ev.accept()
|
||||
return
|
||||
return QTableView.keyPressEvent(self, ev)
|
||||
|
||||
@property
|
||||
def selected_locations(self):
|
||||
return filter(None, (self.proxy.sourceModel().location(self.proxy.mapToSource(index)) for index in self.selectionModel().selectedIndexes()))
|
||||
|
||||
@property
|
||||
def current_location(self):
|
||||
index = self.selectionModel().currentIndex()
|
||||
return self.proxy.sourceModel().location(self.proxy.mapToSource(index))
|
||||
|
||||
def delete_selected(self):
|
||||
locations = self.selected_locations
|
||||
if locations:
|
||||
names = {l.name for l in locations}
|
||||
spine_names = {n for n, l in current_container().spine_names}
|
||||
spine_items = spine_names.intersection(names)
|
||||
other_items = names - spine_names
|
||||
self.delete_requested.emit(spine_items, other_items)
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
pos = self.viewport().mapToGlobal(pos)
|
||||
locations = self.selected_locations
|
||||
m = QMenu(self)
|
||||
if locations:
|
||||
m.addAction(_('Delete selected files'), self.delete_selected)
|
||||
self.customize_context_menu(m, locations, self.current_location)
|
||||
if len(m.actions()) > 0:
|
||||
m.exec_(pos)
|
||||
# }}}
|
||||
|
||||
# Files {{{
|
||||
|
||||
class FilesModel(FileCollection):
|
||||
|
||||
COLUMN_HEADERS = (_('Folder'), _('Name'), _('Size (KB)'), _('Type'))
|
||||
@ -149,6 +213,7 @@ class FilesModel(FileCollection):
|
||||
class FilesWidget(QWidget):
|
||||
|
||||
edit_requested = pyqtSignal(object)
|
||||
delete_requested = pyqtSignal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QWidget.__init__(self, parent)
|
||||
@ -157,16 +222,12 @@ class FilesWidget(QWidget):
|
||||
self.filter_edit = e = QLineEdit(self)
|
||||
l.addWidget(e)
|
||||
e.setPlaceholderText(_('Filter'))
|
||||
self.files = f = QTableView(self)
|
||||
f.setSelectionBehavior(f.SelectRows), f.setSelectionMode(f.SingleSelection)
|
||||
f.doubleClicked.connect(self.double_clicked)
|
||||
self.model = m = FilesModel(self)
|
||||
self.proxy = p = ProxyModel(self)
|
||||
e.textChanged.connect(p.filter_text)
|
||||
p.setSourceModel(m)
|
||||
f.setModel(p)
|
||||
self.files = f = FilesView(m, self)
|
||||
f.delete_requested.connect(self.delete_requested)
|
||||
f.double_clicked.connect(self.double_clicked)
|
||||
e.textChanged.connect(f.proxy.filter_text)
|
||||
l.addWidget(f)
|
||||
f.setSortingEnabled(True)
|
||||
|
||||
self.summary = s = QLabel(self)
|
||||
l.addWidget(s)
|
||||
@ -178,12 +239,13 @@ class FilesWidget(QWidget):
|
||||
|
||||
def __call__(self, data):
|
||||
self.model(data)
|
||||
self.filter_edit.clear()
|
||||
m = self.model
|
||||
self.summary.setText(_('Total uncompressed size of all files: {0} :: Images: {1} :: Fonts: {2}').format(*map(
|
||||
human_readable, (m.total_size, m.images_size, m.fonts_size))))
|
||||
|
||||
def double_clicked(self, index):
|
||||
location = self.model.location(self.proxy.mapToSource(index))
|
||||
location = self.model.location(index)
|
||||
if location is not None:
|
||||
self.edit_requested.emit(location)
|
||||
|
||||
@ -192,52 +254,74 @@ class FilesWidget(QWidget):
|
||||
|
||||
# }}}
|
||||
|
||||
class ImagesModel(QAbstractTableModel):
|
||||
# Images {{{
|
||||
|
||||
COLUMN_HEADERS = [_('Name'), _('Size (KB)'), _('Times used'), _('Width'), _('Height'), _('Image')]
|
||||
class ImagesDelegate(QStyledItemDelegate):
|
||||
|
||||
MARGIN = 5
|
||||
|
||||
def __init__(self, *args):
|
||||
QStyledItemDelegate.__init__(self, *args)
|
||||
self.cache = {}
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
ans = QStyledItemDelegate.sizeHint(self, option, index)
|
||||
entry = index.data(Qt.UserRole)
|
||||
if entry is None:
|
||||
return ans
|
||||
th = self.parent().thumbnail_height
|
||||
width, height = min(th, entry.width), min(th, entry.height)
|
||||
m = self.MARGIN * 2
|
||||
return QSize(max(width + m, ans.width()), height + m + self.MARGIN + ans.height())
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
QStyledItemDelegate.paint(self, painter, option, QModelIndex())
|
||||
entry = index.data(Qt.UserRole)
|
||||
if entry is None:
|
||||
return
|
||||
painter.save()
|
||||
th = self.parent().thumbnail_height
|
||||
k = (th, entry.name)
|
||||
pmap = self.cache.get(k)
|
||||
if pmap is None:
|
||||
pmap = self.cache[k] = self.pixmap(th, entry)
|
||||
if pmap.isNull():
|
||||
bottom = option.rect.top()
|
||||
else:
|
||||
m = 2 * self.MARGIN
|
||||
x = option.rect.left() + (option.rect.width() - m - pmap.width()) // 2
|
||||
painter.drawPixmap(x, option.rect.top() + self.MARGIN, pmap)
|
||||
bottom = m + pmap.height() + option.rect.top()
|
||||
rect = QRect(option.rect.left(), bottom, option.rect.width(), option.rect.bottom() - bottom)
|
||||
if option.state & QStyle.State_Selected:
|
||||
painter.setPen(self.parent().palette().color(QPalette.HighlightedText))
|
||||
painter.drawText(rect, Qt.AlignHCenter | Qt.AlignVCenter, entry.basename)
|
||||
painter.restore()
|
||||
|
||||
def pixmap(self, thumbnail_height, entry):
|
||||
pmap = QPixmap(current_container().name_to_abspath(entry.name)) if entry.width > 0 and entry.height > 0 else QPixmap()
|
||||
scaled, width, height = fit_image(entry.width, entry.height, thumbnail_height, thumbnail_height)
|
||||
if scaled and not pmap.isNull():
|
||||
pmap = pmap.scaled(width, height, transformMode=Qt.SmoothTransformation)
|
||||
return pmap
|
||||
|
||||
|
||||
class ImagesModel(FileCollection):
|
||||
|
||||
COLUMN_HEADERS = [_('Image'), _('Size (KB)'), _('Times used'), _('Resolution')]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
self.files = self.sort_keys = ()
|
||||
self.total_size = 0
|
||||
QAbstractTableModel.__init__(self, parent)
|
||||
|
||||
def columnCount(self, parent=None):
|
||||
return len(self.COLUMN_HEADERS)
|
||||
|
||||
def rowCount(self, parent=None):
|
||||
return len(self.files)
|
||||
|
||||
def headerData(self, section, orientation, role=Qt.DisplayRole):
|
||||
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
|
||||
try:
|
||||
return self.COLUMN_HEADERS[section]
|
||||
except IndexError:
|
||||
pass
|
||||
return QAbstractTableModel.headerData(self, section, orientation, role)
|
||||
FileCollection.__init__(self, parent)
|
||||
|
||||
def __call__(self, data):
|
||||
self.beginResetModel()
|
||||
self.files = data['files']
|
||||
self.files = data['images']
|
||||
self.total_size = sum(map(itemgetter(3), self.files))
|
||||
self.images_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'image')))
|
||||
self.fonts_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'font')))
|
||||
psk = numeric_sort_key
|
||||
self.sort_keys = tuple((psk(entry.dir), psk(entry.basename), entry.size, psk(self.CATEGORY_NAMES.get(entry.category, '')))
|
||||
self.sort_keys = tuple((psk(entry.basename), entry.size, len(entry.usage), (entry.width, entry.height))
|
||||
for entry in self.files)
|
||||
self.endResetModel()
|
||||
|
||||
def sort_key(self, row, col):
|
||||
try:
|
||||
return self.sort_keys[row][col]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def name(self, index):
|
||||
try:
|
||||
return self.files[index.row()].name
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def data(self, index, role=Qt.DisplayRole):
|
||||
if role == Qt.DisplayRole:
|
||||
col = index.column()
|
||||
@ -246,20 +330,77 @@ class ImagesModel(QAbstractTableModel):
|
||||
except IndexError:
|
||||
return None
|
||||
if col == 0:
|
||||
return entry.dir
|
||||
if col == 1:
|
||||
return entry.basename
|
||||
if col == 2:
|
||||
if col == 1:
|
||||
sz = entry.size / 1024.
|
||||
return ('%.2f' % sz if int(sz) != sz else type('')(sz))
|
||||
if col == 2:
|
||||
return type('')(len(entry.usage))
|
||||
if col == 3:
|
||||
return self.CATEGORY_NAMES.get(entry.category)
|
||||
return '%d x %d' % (entry.width, entry.height)
|
||||
if role == Qt.UserRole:
|
||||
try:
|
||||
return self.files[index.row()]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
|
||||
class ImagesWidget(QWidget):
|
||||
|
||||
edit_requested = pyqtSignal(object)
|
||||
delete_requested = pyqtSignal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QWidget.__init__(self, parent)
|
||||
self.l = l = QVBoxLayout(self)
|
||||
self.thumbnail_height = 64
|
||||
|
||||
self.filter_edit = e = QLineEdit(self)
|
||||
l.addWidget(e)
|
||||
e.setPlaceholderText(_('Filter'))
|
||||
self.model = m = ImagesModel(self)
|
||||
self.files = f = FilesView(m, self)
|
||||
f.customize_context_menu = self.customize_context_menu
|
||||
f.delete_requested.connect(self.delete_requested)
|
||||
f.horizontalHeader().sortIndicatorChanged.connect(self.resize_to_contents)
|
||||
self.delegate = ImagesDelegate(self)
|
||||
f.setItemDelegateForColumn(0, self.delegate)
|
||||
f.double_clicked.connect(self.double_clicked)
|
||||
e.textChanged.connect(f.proxy.filter_text)
|
||||
l.addWidget(f)
|
||||
|
||||
try:
|
||||
self.files.horizontalHeader().restoreState(read_state('image-files-table'))
|
||||
except TypeError:
|
||||
self.files.sortByColumn(0, Qt.AscendingOrder)
|
||||
|
||||
def __call__(self, data):
|
||||
self.model(data)
|
||||
self.filter_edit.clear()
|
||||
self.delegate.cache.clear()
|
||||
self.files.resizeRowsToContents()
|
||||
|
||||
def resize_to_contents(self, *args):
|
||||
QTimer.singleShot(0, self.files.resizeRowsToContents)
|
||||
|
||||
def double_clicked(self, index):
|
||||
location = self.model.location(index)
|
||||
if location is not None:
|
||||
self.edit_requested.emit(location)
|
||||
|
||||
def customize_context_menu(self, menu, selected_locations, current_location):
|
||||
if current_location is not None:
|
||||
menu.addAction(_('Edit the image: %s') % current_location.name, partial(self.edit_requested.emit, current_location))
|
||||
|
||||
def save(self):
|
||||
save_state('image-files-table', bytearray(self.files.horizontalHeader().saveState()))
|
||||
# }}}
|
||||
|
||||
# Wrapper UI {{{
|
||||
class ReportsWidget(QWidget):
|
||||
|
||||
edit_requested = pyqtSignal(object)
|
||||
delete_requested = pyqtSignal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QWidget.__init__(self, parent)
|
||||
@ -275,9 +416,16 @@ class ReportsWidget(QWidget):
|
||||
|
||||
self.files = f = FilesWidget(self)
|
||||
f.edit_requested.connect(self.edit_requested)
|
||||
f.delete_requested.connect(self.delete_requested)
|
||||
s.addWidget(f)
|
||||
QListWidgetItem(_('Files'), r)
|
||||
|
||||
self.images = i = ImagesWidget(self)
|
||||
i.edit_requested.connect(self.edit_requested)
|
||||
i.delete_requested.connect(self.delete_requested)
|
||||
s.addWidget(i)
|
||||
QListWidgetItem(_('Images'), r)
|
||||
|
||||
self.splitter.setStretchFactor(1, 500)
|
||||
try:
|
||||
self.splitter.restoreState(read_state('splitter-state'))
|
||||
@ -289,16 +437,20 @@ class ReportsWidget(QWidget):
|
||||
|
||||
def __call__(self, data):
|
||||
self.files(data)
|
||||
self.images(data)
|
||||
|
||||
def save(self):
|
||||
save_state('splitter-state', bytearray(self.splitter.saveState()))
|
||||
save_state('report-page', self.reports.currentRow())
|
||||
self.files.save()
|
||||
self.images.save()
|
||||
|
||||
class Reports(Dialog):
|
||||
|
||||
data_gathered = pyqtSignal(object, object)
|
||||
edit_requested = pyqtSignal(object)
|
||||
refresh_starting = pyqtSignal()
|
||||
delete_requested = pyqtSignal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
Dialog.__init__(self, _('Reports'), 'reports-dialog', parent=parent)
|
||||
@ -312,6 +464,7 @@ class Reports(Dialog):
|
||||
l.addWidget(self.bb)
|
||||
self.reports = r = ReportsWidget(self)
|
||||
r.edit_requested.connect(self.edit_requested)
|
||||
r.delete_requested.connect(self.confirm_delete)
|
||||
|
||||
self.pw = pw = QWidget(self)
|
||||
s.addWidget(pw), s.addWidget(r)
|
||||
@ -330,10 +483,18 @@ class Reports(Dialog):
|
||||
def sizeHint(self):
|
||||
return QSize(950, 600)
|
||||
|
||||
def confirm_delete(self, spine_names, other_names):
|
||||
if not question_dialog(self, _('Are you sure?'), _(
|
||||
'Are you sure you want to delete the selected files?'), det_msg='\n'.join(spine_names | other_names)):
|
||||
return
|
||||
self.delete_requested.emit(spine_names, other_names)
|
||||
QTimer.singleShot(10, self.refresh)
|
||||
|
||||
def refresh(self):
|
||||
self.wait_stack.setCurrentIndex(0)
|
||||
self.setCursor(Qt.BusyCursor)
|
||||
self.pi.startAnimation()
|
||||
self.refresh_starting.emit()
|
||||
t = Thread(name='GatherReportData', target=self.gather_data)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
Loading…
x
Reference in New Issue
Block a user