This commit is contained in:
Kovid Goyal 2024-01-27 08:03:27 +05:30
commit dbc4860ac5
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 108 additions and 82 deletions

View File

@ -5,12 +5,14 @@ __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import itertools import itertools
from io import BytesIO
import math import math
import operator import operator
import os import os
import weakref import weakref
from collections import namedtuple from collections import namedtuple
from functools import wraps from functools import wraps
from PIL import Image
from qt.core import ( from qt.core import (
QAbstractItemView, QApplication, QBuffer, QByteArray, QColor, QDrag, QEasingCurve, QAbstractItemView, QApplication, QBuffer, QByteArray, QColor, QDrag, QEasingCurve,
QEvent, QFont, QHelpEvent, QIcon, QImage, QIODevice, QItemSelection, QEvent, QFont, QHelpEvent, QIcon, QImage, QIODevice, QItemSelection,
@ -22,18 +24,17 @@ from qt.core import (
from textwrap import wrap from textwrap import wrap
from threading import Event, Thread from threading import Event, Thread
from calibre import fit_image, human_readable, prepare_string_for_xml, prints from calibre import fit_image, human_readable, prepare_string_for_xml
from calibre.constants import DEBUG, config_dir, islinux from calibre.constants import DEBUG, config_dir, islinux
from calibre.ebooks.metadata import fmt_sidx, rating_to_stars from calibre.ebooks.metadata import fmt_sidx, rating_to_stars
from calibre.gui2 import ( from calibre.gui2 import config, empty_index, gprefs, rating_font
FunctionDispatcher, config, empty_index, gprefs, is_gui_thread, rating_font,
)
from calibre.gui2.dnd import path_from_qurl from calibre.gui2.dnd import path_from_qurl
from calibre.gui2.gestures import GestureManager from calibre.gui2.gestures import GestureManager
from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.gui2.pin_columns import PinContainer from calibre.gui2.pin_columns import PinContainer
from calibre.utils import join_with_timeout from calibre.utils import join_with_timeout
from calibre.utils.config import prefs, tweaks from calibre.utils.config import prefs, tweaks
from calibre.utils.img import convert_PIL_image_to_pixmap
from polyglot.builtins import itervalues from polyglot.builtins import itervalues
from polyglot.queue import LifoQueue from polyglot.queue import LifoQueue
@ -86,6 +87,8 @@ def handle_enter_press(self, ev, special_action=None, has_edit_cell=True):
def image_to_data(image): # {{{ def image_to_data(image): # {{{
# Although this function is no longer used in this file, it is used in
# other places in calibre. Don't delete it.
ba = QByteArray() ba = QByteArray()
buf = QBuffer(ba) buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly) buf.open(QIODevice.OpenModeFlag.WriteOnly)
@ -718,7 +721,7 @@ CoverTuple = namedtuple('CoverTuple', ['book_id', 'has_cover', 'cache_valid',
@setup_dnd_interface @setup_dnd_interface
class GridView(QListView): class GridView(QListView):
update_item = pyqtSignal(object) update_item = pyqtSignal(object, object)
files_dropped = pyqtSignal(object) files_dropped = pyqtSignal(object)
books_dropped = pyqtSignal(object) books_dropped = pyqtSignal(object)
@ -902,7 +905,6 @@ class GridView(QListView):
def shown(self): def shown(self):
self.update_memory_cover_cache_size() self.update_memory_cover_cache_size()
if self.render_thread is None: if self.render_thread is None:
self.render_cover_dispatcher = FunctionDispatcher(self.render_cover)
self.fetch_thread = Thread(target=self.fetch_covers) self.fetch_thread = Thread(target=self.fetch_covers)
self.fetch_thread.daemon = True self.fetch_thread.daemon = True
self.fetch_thread.start() self.fetch_thread.start()
@ -917,22 +919,17 @@ class GridView(QListView):
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
continue continue
try: try:
# Fetch the cover from the cache or file system on this non- # Fetch the cover from the cache or file system
# GUI thread, putting all file system waits outside the GUI
cover_tuple = self.fetch_cover_from_cache(book_id) cover_tuple = self.fetch_cover_from_cache(book_id)
# Render the cover on the GUI thread. There is no limit on # Render/resize the cover.
# the length of a signal connection queue. Using a thumb = self.make_thumbnail(cover_tuple)
# dispatcher instead of a queued connection prevents
# overloading the GUI with paint requests, which could make
# performance sluggish.
self.render_cover_dispatcher(cover_tuple)
# Tell the GUI to redisplay the cover. These can queue, but # Tell the GUI to redisplay the cover. These can queue, but
# the work is limited to painting the cover if it is visible # the work is limited to painting the cover if it is visible
# so there shouldn't be much performance lag. Using a # so there shouldn't be much performance lag. Using a
# dispatcher to eliminate the queue would probably be worse. # dispatcher to eliminate the queue would probably be worse.
self.update_item.emit(book_id) self.update_item.emit(book_id, thumb)
except: except:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -941,30 +938,40 @@ class GridView(QListView):
def fetch_cover_from_cache(self, book_id): def fetch_cover_from_cache(self, book_id):
''' '''
This method is called on the cover thread, not the GUI thread. It This method fetches the cover from the cache if it exists, otherwise the
returns a namedTuple of cover and cache data: cover.jpg stored in the library.
It is called on the cover thread.
It returns a CoverTuple containing the following cover and cache data:
book_id: The id of the book for which a cover is wanted. book_id: The id of the book for which a cover is wanted.
has_cover: True if the book has an associated cover image file. has_cover: True if the book has an associated cover image file.
cdata: Cover data. Can be None (no cover data), jpg string data, or a cdata: Cover data. Can be None (no cover data), or a rendered cover image.
previously rendered cover.
cache_valid: True if the cache has correct data, False if a cover exists cache_valid: True if the cache has correct data, False if a cover exists
but isn't in the cache, None if the cache has data but the but isn't in the cache, None if the cache has data but the
cover has been deleted. cover has been deleted.
timestamp: the cover file modtime if the cover came from the file system, timestamp: the cover file modtime if the cover came from the file system,
the timestamp in the cache if a valid cover is in the cache, the timestamp in the cache if a valid cover is in the cache,
None otherwise. otherwise None.
''' '''
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
return return
db = self.dbref() db = self.dbref()
if db is None: if db is None:
return return
cdata, timestamp = self.thumbnail_cache[book_id] # None, None if not cached. tc = self.thumbnail_cache
cdata, timestamp = tc[book_id] # None, None if not cached.
if timestamp is None: if timestamp is None:
# Cover not in cache Get the cover from the file system if it exists. # Cover not in cache. Try to read the cover from the library.
has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0) has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0)
cache_valid = False if has_cover else None if has_cover:
# There is a cover.jpg. Convert the byte string to an image.
cache_valid = False
cdata = Image.open(BytesIO(cdata))
else:
# No cover.jpg
cache_valid = None
else: else:
# A cover is in the cache. Check whether it is up to date. # A cover is in the cache. Check whether it is up to date.
has_cover, tcdata, timestamp = db.new_api.cover_or_cache(book_id, timestamp) has_cover, tcdata, timestamp = db.new_api.cover_or_cache(book_id, timestamp)
@ -972,23 +979,22 @@ class GridView(QListView):
if tcdata is None: if tcdata is None:
# The cached cover is up-to-date. # The cached cover is up-to-date.
cache_valid = True cache_valid = True
cdata = Image.open(BytesIO(cdata))
else: else:
# The cached cover is stale # The cached cover is stale
cache_valid = False cache_valid = False
# Replace the cached cover data with the data from the cover file # Convert the bytes from the cover.jpg. The image will be
cdata = tcdata # resized later.
cdata = Image.open(BytesIO(tcdata))
else: else:
# We found a cached cover for a book without a cover. This can # We found a cached cover for a book without a cover. This can
# happen in older version of calibres that can reuse book_ids # happen in older version of calibre that can reuse book_ids
# between libraries and the books in one library have covers # between libraries and books in one library have covers where
# where they don't in another library. Removing the cover from # they don't in another library. Versions of calibre with this
# the cache isn't strictly necessary, but seems better than # source code don't have this problem because the cache UUID is
# leaving incorrect data in the cache. It will get regenarated # set when the database changes instead of when the cache thread
# if the user switches to the other library. Versions of calibre # is created.
# with this source code don't have this problem because the tc.invalidate((book_id,))
# cache UUID is set when the database changes instead of when
# the cache thread is created.
self.thumbnail_cache.invalidate((book_id,))
cache_valid = None cache_valid = None
cdata = None cdata = None
if has_cover and cdata is None: if has_cover and cdata is None:
@ -996,66 +1002,82 @@ class GridView(QListView):
return CoverTuple(book_id=book_id, has_cover=has_cover, cache_valid=cache_valid, return CoverTuple(book_id=book_id, has_cover=has_cover, cache_valid=cache_valid,
cdata=cdata, timestamp=timestamp) cdata=cdata, timestamp=timestamp)
def render_cover(self, cover_tuple): def make_thumbnail(self, cover_tuple):
# Render the cover image data to the current size and correct image format. # Render the cover image data to the thumbnail size and correct format.
# This method must be called on the GUI thread # Rendering isn't needed if the cover came from the cache and the cache
if not is_gui_thread(): # is valid. Put a newly rendered image into the cache. Returns the
raise RuntimeError('render_cover not in GUI Thread') # thumbnail as a PIL Image. This method is called on the cover thread.
cdata = cover_tuple.cdata
book_id = cover_tuple.book_id
tc = self.thumbnail_cache
# thumb = None
if cover_tuple.has_cover:
# cdata contains either the resized thumbnail, the full cover.jpg,
# or None if cover.jpg isn't valid
if cdata.getbbox() is None and cover_tuple.cache_valid:
# Something wrong with the cover data in the cache. Remove it
# from the cache and render it again.
tc.invalidate((book_id,))
self.render_queue.put(book_id)
return None
if not cover_tuple.cache_valid:
# The cover isn't in the cache, is stale, or isn't a valid
# image. We might have the image from cover.jpg, in which case
# make it into a thumbnail.
if cdata is not None:
# We have a cover from the file system. Scale it by creating
# a thumbnail
dpr = self.device_pixel_ratio dpr = self.device_pixel_ratio
page_width = int(dpr * self.delegate.cover_size.width()) page_width = int(dpr * self.delegate.cover_size.width())
page_height = int(dpr * self.delegate.cover_size.height()) page_height = int(dpr * self.delegate.cover_size.height())
cdata = cover_tuple.cdata scaled, nwidth, nheight = fit_image(cdata.width, cdata.height,
book_id = cover_tuple.book_id page_width, page_height)
if cover_tuple.has_cover:
# The book has a cover. Render the cover data as needed to get the
# pixmap that will be cached.
p = QPixmap()
p.loadFromData(cdata, CACHE_FORMAT if cover_tuple.cache_valid else 'JPEG')
p.setDevicePixelRatio(dpr)
if p.isNull() and cover_tuple.cache_valid:
# Something wrong with the cover data. Remove it from the cache
# and render it again.
self.thumbnail_cache.invalidate((book_id,))
self.render_queue.put(book_id)
return
cdata = None if p.isNull() else p
if not cover_tuple.cache_valid:
# cover isn't in the cache, is stale, or isn't a valid image.
if cdata is not None:
# Scale the cover
width, height = p.width(), p.height()
scaled, nwidth, nheight = fit_image(
width, height, page_width, page_height)
if scaled: if scaled:
# The image is the wrong size. Scale it.
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
return return
p = p.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) # The PIL thumbnail operation works in-place, changing
p.setDevicePixelRatio(dpr) # the source image.
cdata = p cdata.thumbnail((int(nwidth), int(nheight)))
if cdata is None: thumb = cdata
# The cover data isn't valid. Remove it from the cache # Put the new thumbnail into the cache.
self.thumbnail_cache.invalidate((book_id,))
else:
# Put the newly scaled cover into the cache.
try: try:
self.thumbnail_cache.insert(book_id, cover_tuple.timestamp, with BytesIO() as buf:
image_to_data(cdata)) cdata.save(buf, format=CACHE_FORMAT)
except EncodeError as err: # use getbuffer() instead of getvalue() to avoid a copy
self.thumbnail_cache.invalidate((book_id,)) tc.insert(book_id, cover_tuple.timestamp, buf.getbuffer())
prints(err) thumb = cdata
except Exception: except Exception:
tc.invalidate((book_id,))
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# else: cached cover image used directly. Nothing to do. else:
elif cover_tuple.cache_valid is not None: # The cover data isn't valid. Remove it from the cache
tc.invalidate((book_id,))
else:
# The data from the cover cache is valid and is already a thumb.
thumb = cdata
else:
# The book doesn't have a cover.
if cover_tuple.cache_valid is not None:
# Cover was removed, but it exists in cache. Remove it from the cache # Cover was removed, but it exists in cache. Remove it from the cache
self.thumbnail_cache.invalidate((book_id,)) tc.invalidate((book_id,))
cdata = None thumb = None
# This can put None into the cache so re-rendering doesn't try again. # Return the thumbnail, which is either None or a PIL Image. If not None
self.delegate.cover_cache.set(book_id, cdata) # the image will be converted to a QPixmap on the GUI thread. Putting
# None into the CoverCache ensures re-rendering won't try again.
return thumb
def re_render(self, book_id): def re_render(self, book_id, thumb):
# This is called on the GUI thread when a cover thumbnail is not in the
# CoverCache. The parameter "thumb" is either None if there is no cover
# or a PIL Image of the thumbnail.
self.delegate.cover_cache.clear_staging() self.delegate.cover_cache.clear_staging()
if thumb is not None:
# Convert the image to a QPixmap
thumb = convert_PIL_image_to_pixmap(thumb)
self.delegate.cover_cache.set(book_id, thumb)
m = self.model() m = self.model()
try: try:
index = m.db.row(book_id) index = m.db.row(book_id)

View File

@ -25,7 +25,12 @@ class ThumbnailCache(TC):
class CoverCache(dict): class CoverCache(dict):
' This is a RAM cache to speed up rendering of covers by storing them as QPixmaps ' '''
This is a RAM cache to speed up rendering of covers by storing them as
QPixmaps. It is possible that it is called from multiple threads, thus the
locking and staging. For example, it can be called by the db layer when a
book is removed either by the GUI or the content server.
'''
def __init__(self, limit=100): def __init__(self, limit=100):
self.items = OrderedDict() self.items = OrderedDict()
@ -59,7 +64,6 @@ class CoverCache(dict):
# faster # faster
ans = QPixmap.fromImage(ans) ans = QPixmap.fromImage(ans)
self.items[key] = ans self.items[key] = ans
return ans return ans
def set(self, key, val): def set(self, key, val):