# -*- coding: utf-8 -*-
#
# surfacecache.py
#
# Copyright 2010 Thomas Jost <thomas.jost@gmail.com>
# Copyright 2015 Cimbali <me@cimba.li>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
"""
:mod:`pympress.surfacecache` -- pages prerendering and caching
--------------------------------------------------------------
This modules contains stuff needed for caching pages and prerendering them. This
is done by the :class:`~pympress.surfacecache.SurfaceCache` class, using several
`dict` of :class:`~cairo.ImageSurface` for storing rendered pages.
The problem is, neither Gtk+ nor Poppler are particularly threadsafe.
Hence the prerendering isn't really done in parallel in another thread, but
scheduled on the main thread at idle times using GLib.idle_add().
"""
import logging
logger = logging.getLogger(__name__)
import threading
import functools
import collections
import gi
import cairo
gi.require_version('Gtk', '3.0')
from gi.repository import GLib
[docs]
class SurfaceCache(object):
""" Pages caching and prerendering made (almost) easy.
Args:
doc (:class:`~pympress.document.Document`): the current document
max_pages (`int`): The maximum page number.
"""
#: The actual cache. The `dict`s keys are widget names and its values are
#: :class:`~collections.OrderedDict`, whose keys are page numbers
#: and values are instances of :class:`~cairo.ImageSurface`.
#: In each :class:`~collections.OrderedDict` keys are ordered by
#: Least Recently Used (get or set), when the size is beyond
#: :attr:`max_pages`, pages are popped from the start of the cache.
surface_cache = {}
#: `dict` containing functions that return a :class:`~cairo.Surface` given a :class:`~cairo.Format`,
#: width `int` and height `int`, see :meth:`~Gtk.Window.create_similar_image_surface`
surface_factory = {}
#: Size of the different managed widgets, as a `dict` of tuples
surface_size = {}
#: Type of document handled by each widget. It is a `dict`: its keys are
#: widget names and its values are document types from ui.
surface_type = {}
#: Dictionary of :class:`~threading.Lock` used for managing conccurent
#: accesses to :attr:`surface_cache` and :attr:`surface_size`
locks = {}
#: Set of widgets for which we ignore the max
unlimited = set()
#: The current :class:`~pympress.document.Document`.
doc = None
#: :class:`~threading.Lock` used to manage conccurent accesses to :attr:`doc`.
doc_lock = None
#: Set of active widgets
active_widgets = set()
#: maximum number of pages we keep in cache
max_pages = 200
def __init__(self, doc, max_pages):
self.max_pages = max_pages
self.doc = doc
self.doc_lock = threading.Lock()
[docs]
def swap_document(self, new_doc):
""" Replaces the current document for which to cache slides with a new one.
This function also clears the cached pages, since they now belong to an outdated document.
Args:
new_doc (:class:`~pympress.document.Document`): the new document
"""
with self.doc_lock:
self.doc = new_doc
self.clear_cache()
[docs]
def disable_prerender(self, widget_name):
""" Remove a widget from the ones to be prerendered.
Args:
widget_name (`str`): string used to identify a widget
"""
self.active_widgets.discard(widget_name)
[docs]
def enable_prerender(self, widget_name):
""" Add a widget to the ones to be prerendered.
Args:
widget_name (`str`): string used to identify a widget
"""
self.active_widgets.add(widget_name)
[docs]
def clear_cache(self, widget_name=None):
""" Remove all cached values for a given widget. Useful for zoomed views.
Args:
widget_name (`str`): name of the widget that is resized, `None` for all widgets.
"""
for widget in [widget_name] if widget_name is not None else self.locks:
with self.locks[widget]:
self.surface_cache[widget].clear()
[docs]
def get(self, widget_name, page_nb):
""" Fetch a cached, prerendered page for the specified widget.
Args:
widget_name (`str`): name of the concerned widget
page_nb (`int`): number of the page to fetch in the cache
Returns:
:class:`~cairo.ImageSurface`: the cached page if available, or `None` otherwise
"""
with self.locks[widget_name]:
pc = self.surface_cache[widget_name]
if page_nb in pc:
pc.move_to_end(page_nb)
return pc[page_nb]
else:
return None
[docs]
def put(self, widget_name, page_nb, val):
""" Store a rendered page in the cache.
Args:
widget_name (`str`): name of the concerned widget
page_nb (`int`): number of the page to store in the cache
val (:class:`~cairo.ImageSurface`): content to store in the cache
"""
with self.locks[widget_name]:
pc = self.surface_cache[widget_name]
pc[page_nb] = val
pc.move_to_end(page_nb)
while len(pc) > self.max_pages:
pc.popitem(False)
def _create_surface(self, widget, fmt, width, height):
""" Given a widget, create a cairo Image surface with appropriate size and scaling.
Args:
widget (:class:`~Gtk.Widget`): the widget for which we’re caching data.
fmt (:class:`~cairo.Format`): the format for the new surface
width (`int`): width of the new surface
height (`int`): height of the new surface
Returns:
:class:`~cairo.ImageSurface`: a new image surface
"""
window = widget.get_window()
scale = window.get_scale_factor()
try:
return window.create_similar_image_surface(fmt, width * scale, height * scale, scale)
except cairo.Error:
logger.warning('Failed creating a {} cache surface sized {}x{} scale {} for widget {}'
.format(fmt, width * scale, height * scale, scale, widget.get_name()), exc_info=True)
# Warn here but handle error at the call site
raise
[docs]
def prerender(self, page_nb):
""" Queue a page for prerendering.
The specified page will be prerendered for all the registered widgets.
Args:
page_nb (`int`): number of the page to be prerendered
"""
for name in self.active_widgets:
GLib.idle_add(self.renderer, name, page_nb)
[docs]
def renderer(self, widget_name, page_nb):
""" Rendering function.
This function is meant to be scheduled on the GLib main loop. When run,
it will go through the following steps:
- check if the job's result is not already available in the cache
- render it in a new :class:`~cairo.ImageSurface` if necessary
- store it in the cache if it was not added there since the beginning of
the process and the widget configuration is still valid
Args:
widget_name (`str`): name of the concerned widget
page_nb (`int`): number of the page to store in the cache
"""
with self.locks[widget_name]:
if page_nb in self.surface_cache[widget_name]:
# Already in cache
return GLib.SOURCE_REMOVE
ww, wh = self.surface_size[widget_name]
wtype = self.surface_type[widget_name]
if ww < 0 or wh < 0:
logger.warning('Widget {} with invalid size {}x{} when rendering'.format(widget_name, ww, wh))
return GLib.SOURCE_REMOVE
with self.doc_lock:
page = self.doc.page(page_nb)
if page is None:
return GLib.SOURCE_REMOVE
# Render to a ImageSurface
try:
surface = self.surface_factory[widget_name](cairo.Format.RGB24, ww, wh)
except AttributeError:
logger.warning('Widget {} was not mapped when rendering'.format(widget_name), exc_info = True)
return GLib.SOURCE_REMOVE
except cairo.Error:
return GLib.SOURCE_REMOVE
context = cairo.Context(surface)
page.render_cairo(context, ww, wh, wtype)
del context
# Save if possible and necessary − using PDF page numbering
page_nb = page.page_nb
with self.locks[widget_name]:
pc = self.surface_cache[widget_name]
if (ww, wh) == self.surface_size[widget_name] and page_nb not in pc:
pc[page_nb] = surface
if widget_name not in self.unlimited:
pc.move_to_end(page_nb)
while len(pc) > self.max_pages:
pc.popitem(False)
return GLib.SOURCE_REMOVE
##
# Local Variables:
# mode: python
# indent-tabs-mode: nil
# py-indent-offset: 4
# fill-column: 80
# end: