# -*- coding: utf-8 -*-
#
# document.py
#
# Copyright 2009, 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.document` -- document handling
---------------------------------------------
This module contains several classes that are used for managing documents (only
PDF documents are supported at the moment, but other formats may be added in the
future).
An important point is that this module is *completely* independant from the GUI:
there should not be any GUI-related code here, except for page rendering (and
only rendering itself: the preparation of the target surface must be done
elsewhere).
"""
from __future__ import print_function, unicode_literals
import logging
logger = logging.getLogger(__name__)
import os
import enum
import tempfile
import mimetypes
import webbrowser
import gi
gi.require_version('Poppler', '0.18')
from gi.repository import Poppler
try:
from urllib.parse import urljoin, scheme_chars
from urllib.request import pathname2url
except ImportError:
from urlparse import urljoin, scheme_chars
from urllib import pathname2url
from pympress.util import fileopen
[docs]def get_extension(mime_type):
""" Returns a valid filename extension (recognized by python) for a given mime type.
Args:
mimetype (`str`): The mime type for which to find an extension
Returns:
`str`: A file extension used for the given mimetype
"""
if not mimetypes.inited:
mimetypes.init()
for ext in mimetypes.types_map:
if mimetypes.types_map[ext] == mime_type:
return ext
[docs]class PdfPage(enum.IntEnum):
""" Represents the part of a PDF page that we want to draw.
"""
#: No notes on PDF page, only falsy value
NONE = 0
#: Full PDF page (without notes)
FULL = 1
#: Bottom half of PDF page
BOTTOM = 2
#: Top half of PDF page
TOP = 3
#: Right half of PDF page
RIGHT = 4
#: Left half of PDF page
LEFT = 5
[docs] def complement(val):
""" Return the enum value for the other part of the page.
"""
return PdfPage(val ^ 1)
[docs] def scale(val):
""" Return the enum value that does only scaling not shifting
"""
return PdfPage(val | 1)
[docs] def from_screen(val, x, y, x2 = None, y2 = None):
""" Transform visible part of the page coordinates to full page coordinates.
Pass 2 floats to transform coordinates, 4 to transform margins,
i.e. the second pair of coordinates is taken from the opposite corner.
Args:
x1 (`float`): x coordinate on the screen, on a scale 0..1
y1 (`float`): y coordinate on the screen, on a scale 0..1
x2 (`float`): second x coordinate on the screen, from the other side, on a scale 0..1
y2 (`float`): second y coordinate on the screen, from the other side, on a scale 0..1
"""
if val == PdfPage.RIGHT:
page = ((1 + x) / 2., y)
elif val == PdfPage.LEFT:
page = (x / 2., y)
elif val == PdfPage.BOTTOM:
page = (x, (1 + y) / 2.)
elif val == PdfPage.TOP:
page = (x, y / 2.)
else:
page = (x, y)
if x2 is None or y2 is None:
return page
else:
return page + val.complement().from_screen(x2, y2)
[docs] def to_screen(val, x, y, x2 = None, y2 = None):
""" Transform full page coordinates to visible part coordinates.
Pass 2 floats to transform coordinates, 4 to transform margins,
i.e. the second pair of coordinates is taken from the opposite corner.
Args:
x1 (`float`): x coordinate on the page, on a scale 0..1
y1 (`float`): y coordinate on the page, on a scale 0..1
x2 (`float`): second x coordinate on the page, from the other side, on a scale 0..1
y2 (`float`): second y coordinate on the page, from the other side, on a scale 0..1
"""
if val == PdfPage.RIGHT:
screen = (x * 2 - 1, y)
elif val == PdfPage.LEFT:
screen = (x * 2, y)
elif val == PdfPage.BOTTOM:
screen = (x, y * 2 - 1)
elif val == PdfPage.TOP:
screen = (x, y * 2)
else:
screen = (x, y)
if x2 is None or y2 is None:
return screen
else:
return screen + val.complement().to_screen(x2, y2)
[docs]class Link(object):
""" This class encapsulates one hyperlink of the document.
Args:
x1 (`float`): first x coordinate of the link rectangle
y1 (`float`): first y coordinate of the link rectangle
x2 (`float`): second x coordinate of the link rectangle
y2 (`float`): second y coordinate of the link rectangle
action (`function`): action to perform when the link is clicked
"""
#: `float`, first x coordinate of the link rectangle
x1 = None
#: `float`, first y coordinate of the link rectangle
y1 = None
#: `float`, second x coordinate of the link rectangle
x2 = None
#: `float`, second y coordinate of the link rectangle
y2 = None
#: `function`, action to be perform to follow this link
follow = lambda *args, **kwargs: logger.error(_("no action defined for this link!"))
def __init__(self, x1, y1, x2, y2, action):
self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2
self.follow = action
[docs] def is_over(self, x, y):
""" Tell if the input coordinates are on the link rectangle.
Args:
x (`float`): input x coordinate
y (`float`): input y coordinate
Returns:
`bool`: `True` if the input coordinates are within the link rectangle, `False` otherwise
"""
return ( (self.x1 <= x) and (x <= self.x2) and (self.y1 <= y) and (y <= self.y2) )
[docs] @staticmethod
def build_closure(fun, *args, **kwargs):
""" Return a lambda that calls fun(\*args, \**kwargs), with the current value of args and kwargs.
By creating the lambda in a new scope, we bind the arguments, thus creating a closure and remember the arguments.
Args:
fun (`function`): The function to be called
args (`tuple`): non-keyworded variable-length argument list to pass to fun()
kwargs (`dict`): keyworded variable-length argument dict to pass to fun()
"""
return lambda *a, **k: fun(*(tuple(args) + tuple(a)), **dict(kwargs, **k))
[docs]class Page(object):
""" Class representing a single page.
It provides several methods used by the GUI for preparing windows for
displaying pages, managing hyperlinks, etc.
Args:
doc (:class:`~Poppler.Page`): the poppler object around the page
number (`int`): number of the page to fetch in the document
parent (:class:`~pympress.document.Document`): the parent Document class
"""
#: Page handled by this class (instance of :class:`~Poppler.Page`)
page = None
#: `int`, number of the current page (starting from 0)
page_nb = -1
#: `str` representing the page label
page_label = None
#: All the links in the page, as a `list` of :class:`~pympress.document.Link` instances
links = []
#: All the media in the page, as a `list` of tuples of (area, filename)
medias = []
#: `float`, page width
pw = 0.
#: `float`, page height
ph = 0.
#: All text annotations
annotations = []
#: Instance of :class:`~pympress.document.Document` that contains this page.
parent = None
def __init__(self, page, number, parent):
self.page = page
self.page_nb = number
self.parent = parent
self.page_label = self.page.get_label()
self.links = []
self.medias = []
self.annotations = []
# Read page size
self.pw, self.ph = self.page.get_size()
# Read links on the page
for link in self.page.get_link_mapping():
action = self.get_link_action(link.action.type, link.action)
my_link = Link(link.area.x1, link.area.y1, link.area.x2, link.area.y2, action)
self.links.append(my_link)
# Read annotations, in particular those that indicate media
for annotation in self.page.get_annot_mapping():
content = annotation.annot.get_contents()
if content:
self.annotations.append(content)
annot_type = annotation.annot.get_annot_type()
if annot_type == Poppler.AnnotType.LINK:
# just an Annot, not subclassed -- probably redundant with links
continue
elif annot_type == Poppler.AnnotType.MOVIE:
movie = annotation.annot.get_movie()
filepath = self.parent.get_full_path(movie.get_filename())
if filepath:
# TODO there is no autoplay, or repeatCount
relative_margins = Poppler.Rectangle()
relative_margins.x1 = annotation.area.x1 / self.pw # left
relative_margins.x2 = 1.0 - annotation.area.x2 / self.pw # right
relative_margins.y1 = annotation.area.y1 / self.ph # bottom
relative_margins.y2 = 1.0 - annotation.area.y2 / self.ph # top
media = (relative_margins, filepath, movie.show_controls())
self.medias.append(media)
action = Link.build_closure(self.parent.play_media, hash(media))
else:
logger.error(_("Pympress can not find file ") + movie.get_filename())
continue
elif annot_type == Poppler.AnnotType.SCREEN:
action_obj = annotation.annot.get_action()
if not action_obj:
continue
action = self.get_annot_action(action_obj.any.type, action_obj, annotation.area)
if not action:
continue
elif annot_type == Poppler.AnnotType.FILE_ATTACHMENT:
attachment = annotation.annot.get_attachment()
prefix, ext = os.path.splitext(attachment.name)
with tempfile.NamedTemporaryFile('wb', suffix=ext, prefix=prefix, delete=False) as f:
# now the file name is shotgunned
filename=f.name
self.parent.remove_on_exit(filename)
if not attachment.save(filename):
logger.error(_("Pympress can not extract attached file"))
continue
action = Link.build_closure(fileopen, filename)
elif annot_type in {Poppler.AnnotType.TEXT, Poppler.AnnotType.POPUP,
Poppler.AnnotType.FREE_TEXT}:
# text-only annotations, hide them from screen
self.page.remove_annot(annotation.annot)
continue
elif annot_type in {Poppler.AnnotType.STRIKE_OUT, Poppler.AnnotType.HIGHLIGHT,
Poppler.AnnotType.UNDERLINE, Poppler.AnnotType.SQUIGGLY,
Poppler.AnnotType.POLYGON, Poppler.AnnotType.POLY_LINE,
Poppler.AnnotType.SQUARE, Poppler.AnnotType.CIRCLE,
Poppler.AnnotType.CARET, Poppler.AnnotType.LINE,
Poppler.AnnotType.STAMP, Poppler.AnnotType.INK}:
# Poppler already renders annotation of these types, nothing more can be done
# even though the rendering isn't always perfect.
continue
else:
logger.warning(_("Pympress can not interpret annotation of type:") + " {} ".format(annot_type))
continue
my_annotation = Link(annotation.area.x1, annotation.area.y1, annotation.area.x2, annotation.area.y2, action)
self.links.append(my_annotation)
[docs] def get_link_action(self, link_type, action):
""" Get the function to be called when the link is followed.
Args:
link_type (:class:`~Poppler.ActionType`): The type of action to be performed
action (:class:`~Poppler.Action`): The atcion to be performed
Returns:
`function`: The function to be called to follow the link
"""
# Poppler.ActionType.RENDITION should only appear in annotations, right? Otherwise how do we know
# where to render it? Any documentation on which action types are admissible in links vs in annots
# is very welcome. For now, link is fallback to annot so contains all action types.
fun = Link.build_closure(logger.warning, _("No action was defined for this link"))
if link_type == Poppler.ActionType.NONE:
fun = None
elif link_type == Poppler.ActionType.GOTO_DEST:
dest_type = action.goto_dest.dest.type
if dest_type == Poppler.DestType.NAMED:
dest = self.parent.doc.find_dest(action.goto_dest.dest.named_dest)
if dest:
fun = Link.build_closure(self.parent.goto, dest.page_num - 1)
elif dest_type != Poppler.DestType.UNKNOWN:
fun = Link.build_closure(self.parent.goto, action.goto_dest.dest.page_num - 1)
elif link_type == Poppler.ActionType.NAMED:
dest_name = action.named.named_dest
dest = self.parent.doc.find_dest(dest_name)
if dest:
fun = Link.build_closure(self.parent.goto, dest.page_num)
elif dest_name == "GoBack":
fun = self.parent.hist_prev
elif dest_name == "GoForward":
fun = self.parent.hist_next
elif dest_name == "FirstPage":
fun = Link.build_closure(self.parent.goto, 0)
elif dest_name == "PrevPage":
fun = Link.build_closure(self.parent.goto, self.page_nb - 1)
elif dest_name == "NextPage":
fun = Link.build_closure(self.parent.goto, self.page_nb + 1)
elif dest_name == "LastPage":
fun = Link.build_closure(self.parent.goto, self.parent.pages_number() - 1)
elif dest_name == "GoToPage":
# Same as the 'G' action which allows to pick a page to jump to
fun = Link.build_closure(self.parent.start_editing_page_number, )
elif dest_name == "Find":
#TODO popup a text box and search results with Page.find_text
# http://lazka.github.io/pgi-docs/Poppler-0.18/classes/Page.html#Poppler.Page.find_text
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\" to \"{}\"").format(link_type, dest_name))
else:
#TODO find out other possible named actions?
fun = Link.build_closure(logger.warning, _("Pympress does not recognize link type \"{}\" to \"{}\"").format(link_type, dest_name))
elif link_type == Poppler.ActionType.LAUNCH:
launch = action.launch
if launch.params:
logger.warning("ignoring params: " + str(launch.params))
filepath = self.parent.get_full_path(launch.file_name)
if not filepath:
logger.error("can not find file " + launch.file_name)
else:
fun = Link.build_closure(fileopen, filepath)
elif link_type == Poppler.ActionType.RENDITION: # Poppler 0.22
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
elif link_type == Poppler.ActionType.MOVIE: # Poppler 0.20
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
elif link_type == Poppler.ActionType.URI:
fun = Link.build_closure(webbrowser.open_new_tab, action.uri.uri)
elif link_type == Poppler.ActionType.GOTO_REMOTE:
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
elif link_type == Poppler.ActionType.OCG_STATE:
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
elif link_type == Poppler.ActionType.JAVASCRIPT:
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
elif link_type == Poppler.ActionType.UNKNOWN:
fun = Link.build_closure(logger.warning, _("Pympress does not yet support link type \"{}\"").format(link_type))
else:
fun = Link.build_closure(logger.warning, _("Pympress does not recognize link type \"{}\"").format(link_type))
return fun
[docs] def get_annot_action(self, link_type, action, rect):
""" Get the function to be called when the link is followed.
Args:
link_type (:class:`~Poppler.ActionType`): The link type
action (:class:`~Poppler.Action`): The action to be performed when the link is clicked
rect (:class:`~Poppler.Rectangle`): The region of the page where the link is
Returns:
`function`: The function to be called to follow the link
"""
if link_type == Poppler.ActionType.RENDITION:
media = action.rendition.media
if media.is_embedded():
ext = get_extension(media.get_mime_type())
with tempfile.NamedTemporaryFile('wb', suffix=ext, prefix='pdf_embed_', delete=False) as f:
# now the file name is shotgunned
filename=f.name
self.parent.remove_on_exit(filename)
if not media.save(filename):
logger.error(_("Pympress can not extract embedded media"))
return None
else:
filename = self.parent.get_full_path(media.get_filename())
if not filename:
logger.error(_("Pympress can not find file ")+media.get_filename())
return None
# TODO grab the show_controls, autoplay, repeat
relative_margins = Poppler.Rectangle()
relative_margins.x1 = rect.x1 / self.pw # left
relative_margins.x2 = 1.0 - rect.x2 / self.pw # right
relative_margins.y1 = rect.y1 / self.ph # bottom
relative_margins.y2 = 1.0 - rect.y2 / self.ph # top
media = (relative_margins, filename, False)
self.medias.append(media)
return Link.build_closure(self.parent.play_media, hash(media))
else:
return self.get_link_action(link_type, action)
[docs] def number(self):
""" Get the page number.
"""
return self.page_nb
[docs] def label(self):
""" Get the page label.
"""
return self.page_label
[docs] def get_link_at(self, x, y, dtype=PdfPage.FULL):
""" Get the :class:`~pympress.document.Link` corresponding to the given
position, or `None` if there is no link at this position.
Args:
x (`float`): horizontal coordinate
y (`float`): vertical coordinate
dtype (:class:`~pympress.document.PdfPage`): the type of document to consider
Returns:
:class:`~pympress.document.Link`: the link at the given coordinates
if one exists, `None` otherwise
"""
x, y = dtype.from_screen(x, y)
xx = self.pw * x
yy = self.ph * (1. - y)
for link in self.links:
if link.is_over(xx, yy):
return link
return None
[docs] def get_size(self, dtype=PdfPage.FULL):
""" Get the page size.
Args:
dtype (:class:`~pympress.document.PdfPage`): the type of document to consider
Returns:
`(float, float)`: page size
"""
return dtype.scale().from_screen(self.pw, self.ph)
[docs] def get_aspect_ratio(self, dtype=PdfPage.FULL):
""" Get the page aspect ratio.
Args:
dtype (:class:`~pympress.document.PdfPage`): the type of document to consider
Returns:
`float`: page aspect ratio
"""
w, h = self.get_size(dtype)
return w / h
[docs] def get_annotations(self):
""" Get the list of text annotations on this page.
Returns:
`list` of `str`: annotations on this page
"""
return self.annotations
[docs] def get_media(self):
""" Get the list of medias this page might want to play.
Returns:
`list`: medias in this page
"""
return self.medias
[docs] def render_cairo(self, cr, ww, wh, dtype=PdfPage.FULL):
""" Render the page on a Cairo surface.
Args:
cr (:class:`~Gdk.CairoContext`): target surface
ww (`int`): target width in pixels
wh (`int`): target height in pixels
dtype (:class:`~pympress.document.PdfPage`): the type of document that should be rendered
"""
pw, ph = self.get_size(dtype)
cr.set_source_rgb(1, 1, 1)
# Scale
scale = min(ww/pw, wh/ph)
cr.scale(scale, scale)
cr.rectangle(0, 0, pw, ph)
cr.fill()
# For "regular" pages, there is no problem: just render them.
# For other pages (i.e. half of a page), the widget already has correct
# dimensions so we don't need to deal with that. But for right and bottom
# halfs we must translate the output in order to only show the correct half.
if dtype == PdfPage.RIGHT:
cr.translate(-pw, 0)
elif dtype == PdfPage.BOTTOM:
cr.translate(0, -ph)
self.page.render(cr)
[docs] def can_render(self):
""" Informs that rendering *is* necessary (avoids checking the type)
Returns:
`bool`: `True`, do rendering
"""
return True
[docs]class Document(object):
""" This is the main document handling class.
.. note:: The internal page numbering scheme is the same as in Poppler: it
starts at 0.
Args:
builder (:class:`pympress.builder.Builder`): A builder to load callbacks
pop_doc (:class:`~pympress.Poppler.Document`): Instance of the Poppler document that this class will wrap
path (`str`): Absolute path to the PDF file to open
page (`int`): page number to which the file should be opened
"""
#: Current PDF document (:class:`~Poppler.Document` instance)
doc = None
#: Path to pdf
path = None
#: Number of pages in the document
nb_pages = -1
#: Number of the current page
cur_page = -1
#: :class:`~pympress.document.PdfPage` representing our best guess for the notes position in this document
notes = PdfPage.NONE
#: Pages cache (`dict` of :class:`~pympress.document.Page`). This makes
#: navigation in the document faster by avoiding calls to Poppler when loading
#: a page that has already been loaded.
pages_cache = {}
#: Files that are temporary and need to be removed
temp_files = set()
#: History of pages we have visited
history = []
#: Our position in the history
hist_pos = -1
#: `dict` of all the page labels
page_labels = []
#: callback, to be connected to :func:`~pympress.ui.UI.on_page_change`
page_change = lambda p: None
#: callback, to be connected to :func:`~pympress.extras.Media.play`
play_media = lambda h: None
#: callback, to be connected to :func:`~pympress.editable_label.PageNumber.start_editing`
start_editing_page_number = lambda: None
def __init__(self, builder, pop_doc, path, page=0):
# Connect callbacks
self.play_media = builder.get_callback_handler('medias.play')
self.page_change = builder.get_callback_handler('on_page_change')
self.start_editing_page_number = builder.get_callback_handler('page_number.start_editing')
# Setup PDF file
self.path = path
self.doc = pop_doc
# Pages number
self.nb_pages = self.doc.get_n_pages()
self.page_labels = [self.doc.get_page(n).get_label() for n in range(self.nb_pages)]
# Number of the current page
self.cur_page = page
self.history.append(page)
self.hist_pos = 0
# Pages cache
self.pages_cache = {}
# Guess if the document has notes
page0 = self.page(page)
if page0 is not None:
# "Regular" pages will have an apsect ratio of 4/3, 16/9, 16/10...
# Full A4 pages will have an aspect ratio < 1.
# So if the aspect ratio is >= 2, we can assume it is a document with notes.
ar = page0.get_aspect_ratio()
if ar >= 2:
self.notes = PdfPage.RIGHT
elif ar < 1:
self.notes = PdfPage.BOTTOM
else:
self.notes = PdfPage.NONE
[docs] def get_structure(self, index_iter = None):
""" Gets the structure of the document from its index.
Recursive, pass the iterator.
Args:
index_iter (:class:`~Poppler.IndexIter` or `None`): the iterator for the child index to explore.
Returns:
`list`: A list of tuples (depth, page number, title)
"""
try:
if index_iter is None:
index_iter = Poppler.IndexIter(self.doc)
except TypeError:
return {}
if index_iter is None:
return {}
index = {}
while True:
action = index_iter.get_action()
title = ''
try:
if action.type == Poppler.ActionType.GOTO_DEST:
title = action.goto_dest.title
if action.goto_dest.dest.type == Poppler.DestType.NAMED:
dest = self.parent.doc.find_dest(action.goto_dest.dest.named_dest)
page = dest.page_num - 1
elif action.goto_dest.dest.type == Poppler.DestType.UNKNOWN:
raise AssertionError('Unknown type of destination')
else:
page = action.goto_dest.dest.page_num - 1
else:
raise AssertionError('Unexpected type of action')
except:
logger.error(_('Unexpected action in index "{}"').format(action.type))
page = None
new_entry = {'title': title}
child = index_iter.get_child()
if child:
new_entry['children'] = self.get_structure(child)
# there should not be synonymous sections, correct the page here to a better guess
if page is None or page in index:
if 'children' in new_entry:
page = min(new_entry['children'])
else:
lower_bound = max(index)
find = index[lower_bound]
while 'children' in find:
lower_bound = max(find)
find = find[lower_bound]
try:
page = min(l for l, n in enumerate(self.page_labels) if n == self.page_labels[page] and l > lower_bound)
except ValueError: # empty iterator
page = lower_bound + 1
index[page] = new_entry
if not index_iter.next():
break
return index
[docs] @staticmethod
def path_to_uri(path):
# Do not trust urlsplit, manually check we have an URI
pos = path.index(':') if ':' in path else -1
if path[pos:pos+3] == '://' or (pos > 1 and set(path[:pos]) <= scheme_chars):
return path
else:
return urljoin('file:', pathname2url(path))
[docs] @staticmethod
def create(builder, path, page=0):
""" Initializes a Document by passing it a :class:`~Poppler.Document`
Args:
builder (:class:`pympress.builder.Builder`): A builder to load callbacks
path (`str`): Absolute path to the PDF file to open
page (`int`): page number to which the file should be opened
Returns:
:class:`~pympress.document.Document`: The initialized document
"""
if path is None:
doc = EmptyDocument()
else:
uri = Document.path_to_uri(path)
poppler_doc = Poppler.Document.new_from_file(uri, None)
doc = Document(builder, poppler_doc, path, page)
return doc
[docs] def guess_notes(self):
""" Get our best guess for the document mode.
Returns:
:class:`~pympress.document.PdfPage`: the notes mode
"""
return self.notes
[docs] def page(self, number):
""" Get the specified page.
Args:
number (`int`): number of the page to return
Returns:
:class:`~pympress.document.Page`: the wanted page, or `None` if it does not exist
"""
if number >= self.nb_pages or number < 0:
return None
if not number in self.pages_cache:
self.pages_cache[number] = Page(self.doc.get_page(number), number, self)
return self.pages_cache[number]
[docs] def current_page(self):
""" Get the current page.
Returns:
:class:`~pympress.document.Page`: the current page
"""
return self.page(self.cur_page)
[docs] def next_page(self):
""" Get the next page.
Returns:
:class:`~pympress.document.Page`: the next page, or `None` if this is the last page
"""
return self.page(self.cur_page + 1)
[docs] def pages_number(self):
""" Get the number of pages in the document.
Returns:
`int`: the number of pages in the document
"""
return self.nb_pages
def _do_page_change(self, number):
""" Perform the actual change of page and UI notification.
The page number is **not** checked here, so it must be within bounds already.
Args:
number (`int`): number of the destination page
"""
self.cur_page = number
self.page_change()
[docs] def has_labels(self):
""" Return whether this document has useful labels.
Returns:
`bool`: False iff there are no labels or they are just the page numbers
"""
return self.page_labels != [str(n+1) for n in range(self.nb_pages)]
[docs] def lookup_label(self, label, prefix_unique = True):
""" Find a page from its label
Args:
label (`str`): the label we are searching for
prefix_unique (`bool`): whether a prefix match should be unique, e.g. when the user is still typing
Returns:
`int`: the page
"""
# somehow this always returns None
#page = self.doc.get_page_by_label(label).get_index()
# make a shortlist: squash synonymous labels, keeping the last one
compatible_labels = {l: n for n, l in enumerate(self.page_labels) if l.lower().startswith(label.lower())}
if len(compatible_labels) == 1:
return set(compatible_labels.values()).pop()
# try exact match
try: return compatible_labels[label]
except KeyError: pass
# try case-insensitive match, prefix case-sensitive match, prefix case-insensitive match (if prefix_unique = False)
full = len(label)
for filtering in [lambda l: len(l) == full, lambda l: l.startswith(label), lambda l: not prefix_unique]:
try:
found = next(l for l in compatible_labels if filtering(l))
except StopIteration:
continue
return compatible_labels[found]
else:
return None
[docs] def goto(self, number):
""" Switch to another page.
Args:
number (`int`): number of the destination page
"""
if number < 0:
number = 0
if number >= self.nb_pages:
number = self.nb_pages - 1
if number != self.cur_page:
# chop off history where we were and go to end
self.hist_pos += 1
if self.hist_pos < len(self.history):
self.history = self.history[:self.hist_pos]
self.history.append(number)
self._do_page_change(number)
[docs] def goto_next(self, *args):
""" Switch to the next page.
"""
self.goto(self.cur_page + 1)
[docs] def goto_prev(self, *args):
""" Switch to the previous page.
"""
self.goto(self.cur_page - 1)
[docs] def goto_home(self, *args):
""" Switch to the first page.
"""
self.goto(0)
[docs] def goto_end(self, *args):
""" Switch to the last page.
"""
self.goto(self.nb_pages-1)
[docs] def label_after(self, page):
""" Switch to the next page with different label.
If we're within a set of pages with the same label we want to go to the last one.
"""
labels_after = enumerate(self.page_labels[page + 1:], page + 1)
try:
next_page, next_label = next(labels_after)
except StopIteration:
# we're already at the last page!
return page
# will stop as soon as next_page + 1 (aka following_page) is a different label or due to end of iterator
for following_page, following_label in labels_after:
if following_label == next_label:
next_page = following_page
else:
break
return next_page
[docs] def label_before(self, page):
""" Switch to the previous page with different label
If we're within a set of pages with the same label we want to go *before* the first one.
"""
# will stop as soon as we find a different label or due to end of iterator
for prev_page, prev_label in enumerate(reversed(self.page_labels[:page])):
if prev_label != self.page_labels[page]:
return page - 1 - prev_page
else:
return 0
[docs] def label_next(self, *args):
""" Switch to the next page with different label
"""
self.goto(self.label_after(self.cur_page))
[docs] def label_prev(self, *args):
""" Switch to the previous page with different label
"""
self.goto(self.label_before(self.cur_page))
[docs] def hist_next(self, *args):
""" Switch to the page we viewed next
"""
if self.hist_pos + 1 == len(self.history):
return
self.hist_pos += 1
self._do_page_change(self.history[self.hist_pos])
[docs] def hist_prev(self, *args):
""" Switch to the page we viewed before
"""
if self.hist_pos == 0:
return
self.hist_pos -= 1
self._do_page_change(self.history[self.hist_pos])
[docs] def get_uri(self):
"""
Returns:
`str`: the URI to the file currently opened.
"""
return self.path_to_uri(self.path)
[docs] def get_full_path(self, filename):
""" Returns full path, extrapolated from a path relative to this document
or to the current directory.
Args:
filename (`str`): Name of the file or relative path to it
Returns:
`str`: the full path to the file or None if it doesn't exist
"""
filepath = None
if os.path.isabs(filename):
return os.path.normpath(filename) if os.path.exists(filename) else None
for d in [os.path.dirname(self.path), os.getcwd()]:
filepath = os.path.normpath(os.path.join(d, filename))
if os.path.exists(filepath):
return filepath
[docs] def remove_on_exit(self, filename):
""" Remember a temporary file to delete later
Args:
filename (`str`): The path to the file to delete
"""
self.temp_files.add(filename)
[docs]class EmptyPage(Page):
""" A dummy page, placeholder for when there are no valid pages around.
This page is a non-notes page with an aspect ratio of 1.3 and nothing else inside.
Also, it has no "rendering" capability, and is made harmless by overriding its render function.
"""
def __init__(self):
self.page = None
self.page_nb = -1
self.parent = None
self.page_label = None
self.links = []
self.medias = []
self.annotations = []
# by default, anything that will have a 1.3 asapect ratio
self.pw, self.ph = 1.3, 1.0
[docs] def render_cairo(self, cr, ww, wh, dtype=PdfPage.FULL):
""" Overriding this purely for safety: make sure we do not accidentally try to render
Args:
cr (:class:`~Gdk.CairoContext`): target surface
ww (`int`): target width in pixels
wh (`int`): target height in pixels
dtype (:class:`~pympress.document.PdfPage`): the type of document that should be rendered
"""
pass
[docs] def can_render(self):
""" Informs that rendering is *not* necessary (avoids checking the type)
Returns:
`bool`: `False`, no rendering
"""
return False
[docs]class EmptyDocument(Document):
""" A dummy document, placeholder for when no document is open.
"""
def __init__(self):
self.path = None
self.doc = None
self.nb_pages = 0
self.cur_page = -1
self.pages_cache = {-1: EmptyPage()}
self.notes = False
[docs] def page(self, number):
""" Retreive a page from the document.
Args:
number (`int`): page number to be retreived
Returns:
:class:`~pympress.document.EmptyPage` or `None`: -1 returns the empty page so we can display something.
"""
return self.pages_cache[number] if number in self.pages_cache else None
##
# Local Variables:
# mode: python
# indent-tabs-mode: nil
# py-indent-offset: 4
# fill-column: 80
# end: