Repository: hnesk/browse-ocrd (language: python)

Issue:
add PAGE annotation view
Without re-inventing the wheel for displaying PAGE annotations, there is a lot of added-value in having one view option for this here. PageViewer does not know of OCR-D's relative path convention and can only show isolated pages, LAREX cannot cope with METS and OCR-D directory structures.

We already discussed integrating PageViewer loosely by just triggering a command-line call (in the simplest case, using `--resolve-dir workspace-directory`), or adding some IPC capability to PageViewer itself and then remote-controlling it from ocrd_browser. 

Alternatively, one might be able to integrate [nw-page-editor](https://github.com/mauvilsa/nw-page-editor)'s HTML via GTK's WebKit component.


Integrate page-xml-draw
There is a [new library](https://github.com/GBN-DBP/page-xml-draw/tree/master/page_xml_draw) that can generate an opencv image from Page-Xml. 
It would be great to use it for visualizing page-xml in browse-ocrd
Ideas (from simple to complex):
1. Use the generated image directly in an image view
2. Wrap the image in HTML with an imagemap with actionable areas  and display it via a Webkit view.
3. Write a special view for interacting with the library, that renders shapely-"polygons" and tests for actionable areas


Discussion / hints:
As for the PageViewer CLI call, in the simplest case we could add a simple button in the XmlView action bar. We should offer some way to configure the exact command line to use, though. Perhaps in the menu under a new "settings" widget? As a first step, we could just query an environment variable, so we would have to do e.g. `export PAGEVIEWER="java -jar path/to/JPageViewer.jar"` before starting ocrd_browser.
> PageViewer does not know of OCR-D's relative path convention

~~~
jpageviewer () {
	_jpageviewer_resolve_dir=`dirname "$1"` 
	if [ -e "$_jpageviewer_resolve_dir"/../mets.xml ]
	then
		_jpageviewer_resolve_dir="$_jpageviewer_resolve_dir"/.. 
	fi
	java -Dhttp.proxyHost=http-proxy.sbb.spk-berlin.de -Dhttp.proxyPort=3128 -jar $_jpageviewer_jar --resolve-dir "$_jpageviewer_resolve_dir" "$1"
	unset _jpageviewer_resolve_dir
}
~~~

(Of course, the proxy settings are specific to my environment)
@mikegerber thanks for sharing your recipe – but I think in this case we don't need to guess where the workspace directory is relative to the PAGE file path, because we already control all (absolute) paths. We can just call whatever the user configured as base command and append `--resolve-dir workspace-directory page-file image-file`. (The `resolve-dir` arg is still useful because the use might want to change to a different file interactively.)
#21 brought a partial fix.
> Without re-inventing the wheel for displaying PAGE annotations,

Not sure if avoiding re-inventing the wheel is the right thing here, it's just drawing a few polygons... Page Viewer is good but it also has the problem that it's hard to fix problems or add functionality
> Not sure if avoiding re-inventing the wheel is the right thing here, it's just drawing a few polygons... Page Viewer is good but it also has the problem that it's hard to fix problems or add functionality

I fully agree – hence [this recommendation](https://github.com/hnesk/browse-ocrd/issues/28#issuecomment-774689470)
I would love to see 3 (i.e. fixing #15) as it is the most powerful option, going forward. It should still be based on a WebKit view IMO.

I don't think the polygon extraction and image drawing warrants adding an extra dependency (above `ocrd` itself and `pillow`), though. It could be done even more lightly here. 

Roughly:
1. [PageViewer's color scheme](https://github.com/OCR-D/ocrd_segment/blob/549e07910c819de92695d97c6865def8a3a061d7/ocrd_segment/extract_pages.py#L33-L88)
2. [RGBA conversion of raw image](https://github.com/OCR-D/ocrd_segment/blob/549e07910c819de92695d97c6865def8a3a061d7/ocrd_segment/extract_pages.py#L221)
3. [iterating through all (active) segments of the PcGts instance](https://github.com/OCR-D/ocrd_segment/blob/549e07910c819de92695d97c6865def8a3a061d7/ocrd_segment/extract_pages.py#L230-L286)
4. [validating their coordinates](https://github.com/OCR-D/ocrd_segment/blob/549e07910c819de92695d97c6865def8a3a061d7/ocrd_segment/extract_pages.py#L364-L380)
5. [drawing by alpha-compositing](https://github.com/OCR-D/ocrd_segment/blob/549e07910c819de92695d97c6865def8a3a061d7/ocrd_segment/extract_pages.py#L417-L422)
I think 1 seems like a downgrade when compared to PAGE viewer, it would be either a static image view (no different than running a processor like `ocrd-segment-extract-pages` or `ocrd-page-xml-draw` - which I have plans to implement - and then opening the resultant fileGrp in `browse-ocrd`), or a "dynamic" view where the whole image would have to be redrawn in order to display the annotation set selected by the user.

I also agree with @bertsky on integrating `page-xml-draw`: `browse-ocrd` already imports `ocrd`, which contains a very similar PAGE-XML parser/traverser to the one I built `page-xml-draw` on top of (`ocrd_models.ocrd_page_generateds` - both `ocrd` and `page-xml-draw` use GenerateDS with user-defined methods). The drawing of the polygons itself is also really simple, and `browse-ocrd` already depends on `opencv` and `pillow`, so the same goal could be achieved without extra dependencies. The only advantage of an integration would be the customizability provided by `page-xml-draw`, allowing the user to describe how the PAGE-XML tree must be traversed, which visited element types should be drawn and with which fill color, stroke color, stroke thickness, opacity, etc. to be used, but I fear this is not adequate for a GUI.

I have been doing some experiments with HTML image maps to study how 3 could be implemented with the WebView. I came across [this library](https://github.com/jamietre/ImageMapster) and [adapted the `page-xml-draw` code](https://github.com/GBN-DBP/page-xml-draw/tree/dev) to export the polygons from PAGE-XML as HTML map areas, with a JS script using JQuery and the ImageMapster library to render areas of different annotation types with different colors. The results can be found [here](https://www.inf.ufpr.br/ls17/Kirchenblatt_1917-10-15.ocrd.zip). It did not work with `browse-ocrd` out of the box, so I made [some modifications](https://github.com/sulzbals/browse-ocrd/tree/image-map) to visualize the results. There is still a lot of room for improvement both in the process of generating these image maps and in displaying them with `browse-ocrd`, but it looks promising. Something like this could be implemented in `browse-ocrd`, and then displayed in a special HTML view with buttons connected to JS routines so the user could select which layout annotations to display, similarly to PAGE viewer.

![2021-03-15-153621_1600x876_scrot](https://user-images.githubusercontent.com/33208157/111206281-ee0fa480-85a6-11eb-99c9-7c18e82d5533.png)

Fix patch:
diff --git a/gresources/icons/icon-feature-border.svg b/gresources/icons/icon-feature-border.svg
new file mode 100644
index 0000000..facc6fd
--- /dev/null
+++ b/gresources/icons/icon-feature-border.svg
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   version="1.1"
+   width="16"
+   height="16">
+  <svg:style>
+    /* #printspace {display:none;} */
+  </svg:style>
+  <g id="page">
+    <path
+       d="m 2.25,0.75 h 9.25 l 2.25,2.5 v 12 H 2.25 Z"
+       style="fill:#ffffff;stroke:#d2d2d2;stroke-width:0.25;"
+    />
+  </g>
+  <g id="border">
+    <path
+       d="m 2.25,0.75 h 9.25 l 2.25,2.5 v 12 H 2.25 Z"
+       style="fill:none;stroke:#ff4545;stroke-width:0.5;"
+    />
+  </g>
+  <g id="printspace">
+    <rect
+       style="fill:none;stroke:#ff4545;stroke-width:0.5"
+       id="rect885"
+       width="10"
+       height="12"
+       x="3"
+       y="1.5" />
+    />
+  </g>
+
+</svg>
diff --git a/gresources/icons/icon-features.svg b/gresources/icons/icon-features.svg
new file mode 100644
index 0000000..66b291a
--- /dev/null
+++ b/gresources/icons/icon-features.svg
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   height="16"
+   width="16"
+   version="1.1"
+   id="svg8"
+   sodipodi:docname="icon-features.svg"
+   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
+   inkscape:export-filename="/home/jk/PycharmProjects/ocrd-browser/gresources/icons/split-vertical.png"
+   inkscape:export-xdpi="300"
+   inkscape:export-ydpi="300">
+  <metadata
+     id="metadata14">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs12" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="4256"
+     inkscape:window-height="2266"
+     id="namedview10"
+     showgrid="false"
+     inkscape:zoom="32"
+     inkscape:cx="7.99014"
+     inkscape:cy="7.9999988"
+     inkscape:window-x="0"
+     inkscape:window-y="54"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="feature-printspace"
+     fit-margin-left="2"
+     fit-margin-right="2"
+     fit-margin-bottom="2"
+     showguides="false"
+     inkscape:guide-bbox="true"
+     inkscape:pagecheckerboard="true">
+    <sodipodi:guide
+       position="8.3711865,15.016863"
+       orientation="0,1"
+       id="guide831"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       position="8.6768481,0.99586553"
+       orientation="0,1"
+       id="guide833"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       position="2.0114512,9.2881715"
+       orientation="1,0"
+       id="guide837"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       position="14.030858,9.5050927"
+       orientation="1,0"
+       id="guide839"
+       inkscape:locked="false" />
+  </sodipodi:namedview>
+  <g
+     inkscape:groupmode="layer"
+     id="base"
+     inkscape:label="feature-base"
+     style="display:inline">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#d2d2d2;stroke-width:0.25;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 2.25,0.75 h 9.25 l 2.25,2.5 v 12 H 2.25 Z"
+       id="rect829"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cccccc" />
+  </g>
+  <g
+     inkscape:label="feature-border"
+     id="feature-border"
+     inkscape:groupmode="layer"
+     style="display:inline">
+    <path
+       sodipodi:nodetypes="cccccc"
+       inkscape:connector-curvature="0"
+       id="path861"
+       d="m 2.25,0.75 h 9.25 l 2.25,2.5 v 12 H 2.25 Z"
+       style="opacity:1;fill:none;fill-opacity:1;stroke:#ff4545;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+  </g>
+  <g
+     inkscape:groupmode="layer"
+     id="feature-printspace"
+     inkscape:label="feature-printspace">
+    <rect
+       style="fill:none;stroke:#ff4545;stroke-width:0.5"
+       id="rect885"
+       width="10"
+       height="12"
+       x="3"
+       y="1.5" />
+  </g>
+</svg>
diff --git a/gresources/ocrd-browser.gresource.xml b/gresources/ocrd-browser.gresource.xml
index ccba7ba..c1e1070 100644
--- a/gresources/ocrd-browser.gresource.xml
+++ b/gresources/ocrd-browser.gresource.xml
@@ -7,6 +7,7 @@
     <file compressed="true" preprocess="to-pixdata">icons/page-missing.png</file>
     <file compressed="true" preprocess="xml-stripblanks">icons/split-horizontal.svg</file>
     <file compressed="true" preprocess="xml-stripblanks">icons/split-vertical.svg</file>
+    <file compressed="true" preprocess="xml-stripblanks">icons/icon-feature-border.svg</file>
     <file compressed="true" preprocess="xml-stripblanks">gtk/menus.ui</file>
     <file compressed="true">css/theme.css</file>
   </gresource>
diff --git a/ocrd_browser/application.py b/ocrd_browser/application.py
index 98e8c3b..1de4661 100644
--- a/ocrd_browser/application.py
+++ b/ocrd_browser/application.py
@@ -9,6 +9,14 @@
 
 
 class OcrdBrowserApplication(Gtk.Application):
+    # TODO: Parse arguments (with Gtk or click) to open certain views by mets+page_id+view(view_configuration_dict) and deep filename e.g.
+    # OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP/OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP_0001.xml ->
+    # file_grp => OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP /     <mets:fileGrp USE="OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP"><mets:file MIMETYPE="application/vnd.prima.page+xml" ID="OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP_0001"><mets:FLocat LOCTYPE="OTHER" OTHERLOCTYPE="FILE" xlink:href="OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP/OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP_0001.xml"/></mets:file>
+    # mimetype => application/vnd.prima.page+xml
+    # FILEID => OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP_0001 /  <mets:fptr FILEID="OCR-D-OCR-TESS-frk-SEG-LINE-tesseract-ocropy-DEWARP_0001"/>
+    # page.id => PHYS_0017 / <mets:div TYPE="page" ID="PHYS_0017">
+    # mets.xml + page.id + view_by_mimetype(mimetype)(file_grp = (file_grp,mimetype))
+    # TODO: Test application startup for all kinds of mets.xml (not necessarily OCR-D mets.xml) and views without crashes
     def __init__(self) -> None:
         Gtk.Application.__init__(self, application_id='org.readmachine.ocrd-browser',
                                  flags=Gio.ApplicationFlags.HANDLES_OPEN)
@@ -29,6 +37,7 @@ def do_startup(self) -> None:
     def load_css(self) -> None:
         css = Gtk.CssProvider()
         css.load_from_resource('/org/readmachine/ocrd-browser/css/theme.css')
+        # noinspection PyArgumentList
         Gtk.StyleContext().add_provider_for_screen(Gdk.Screen.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_USER)
         # css = Gtk.CssProvider()
         # css.load_from_path('/home/jk/PycharmProjects/ocrd-browser/gresources/css/test.css')
diff --git a/ocrd_browser/model/__init__.py b/ocrd_browser/model/__init__.py
index 57ca21e..4fb0dcb 100644
--- a/ocrd_browser/model/__init__.py
+++ b/ocrd_browser/model/__init__.py
@@ -1,4 +1,5 @@
-from .page import Page  # noqa E402
 from .document import Document  # noqa E402
+from .page import Page, LazyPage  # noqa E402
+from .page_xml_renderer import PageXmlRenderer
 
-__all__ = ['Page', 'Document']
+__all__ = ['Page', 'LazyPage', 'Document', 'PageXmlRenderer']
diff --git a/ocrd_browser/model/document.py b/ocrd_browser/model/document.py
index bbc4b04..33eec28 100644
--- a/ocrd_browser/model/document.py
+++ b/ocrd_browser/model/document.py
@@ -1,12 +1,12 @@
 import atexit
 import errno
 import os
-import re
 import shutil
 from functools import wraps
 
 from ocrd import Workspace, Resolver
-from ocrd_browser.model import Page
+from ocrd_browser.model.page import Page, LazyPage
+from ocrd_browser.util.file_groups import best_file_group
 from ocrd_browser.util.image import add_dpi_to_png_buffer
 from ocrd_browser.util.streams import SilencedStreams
 from ocrd_modelfactory import page_from_file
@@ -279,28 +279,8 @@ def get_image_paths(self, file_group: str) -> Dict[str, Path]:
         return image_paths
 
     def get_default_image_group(self, preferred_image_file_groups: Optional[List[str]] = None) -> Optional[str]:
-        image_file_groups = []
-        for file_group, mimetype in self.file_groups_and_mimetypes:
-            weight = 0.0
-            if mimetype.split('/')[0] == 'image':
-                # prefer images
-                weight += 0.5
-            if preferred_image_file_groups:
-                for i, preferred_image_file_group in enumerate(preferred_image_file_groups):
-                    if re.fullmatch(preferred_image_file_group, file_group):
-                        # prefer matches earlier in the list
-                        weight += (len(preferred_image_file_groups) - i)
-                        break
-            # prefer shorter `file_group`s
-            weight -= len(file_group) * 0.00001
-            image_file_groups.append((file_group, weight))
-        # Sort by weight
-        image_file_groups = sorted(image_file_groups, key=lambda e: e[1], reverse=True)
-
-        if len(image_file_groups) > 0:
-            return image_file_groups[0][0]
-        else:
-            return None
+        best_group = best_file_group(self.file_groups_and_mimetypes, preferred_image_file_groups, [r'image/.*'], cutoff=0)
+        return best_group[0] if best_group else None
 
     def get_unused_page_id(self, template_page_id: str = 'PAGE_{page_nr}') -> Tuple[str, int]:
         """
@@ -335,7 +315,7 @@ def display_id_range(self, page_id: str, page_qty: int) -> List[str]:
         index = index - index % page_qty
         return self.page_ids[index:index + page_qty]
 
-    def page_for_id(self, page_id: str, file_group: str = None) -> Optional['Page']:
+    def _page_for_id_eager(self, page_id: str, file_group: str = None) -> Optional['Page']:
         """
         Find the Page object for page_id and file_group, including any PAGE-XML file and image files.
 
@@ -371,6 +351,20 @@ def page_for_id(self, page_id: str, file_group: str = None) -> Optional['Page']:
 
         return Page(page_id, file, pcgts, image_files, images)
 
+    def _page_for_id_lazy(self, page_id: str, file_group: str = None) -> Optional['Page']:
+        log = getLogger('ocrd_browser.model.document.Document.page_for_id')
+        if not page_id:
+            return None
+        page = LazyPage(self, page_id, file_group)
+        if not page.file:
+            log.warning("No PAGE-XML and no image for page '{}' in fileGrp '{}'".format(page_id, file_group))
+            return None
+
+        return page
+
+    # page_for_id = _page_for_id_eager
+    page_for_id = _page_for_id_lazy
+
     def files_for_page_id(self, page_id: str, file_group: str = None, mimetype: str = None) -> List[OcrdFile]:
         with pushd_popd(self.workspace.directory):
             files: List[OcrdFile] = self.workspace.mets.find_files(fileGrp=file_group, pageId=page_id,
@@ -379,15 +373,14 @@ def files_for_page_id(self, page_id: str, file_group: str = None, mimetype: str
             return files
 
     def page_for_file(self, page_file: OcrdFile) -> PcGtsType:
-        with pushd_popd(self.workspace.directory):
-            # Silence Warning: Value "ocrd-cis-word-alignment" ... does not match xsd enumeration restriction on TextDataTypeSimpleType
-            with SilencedStreams(False, True):
-                return page_from_file(page_file)
+        # cd and silence Warning: Value "ocrd-cis-word-alignment" ... does not match xsd enumeration restriction on TextDataTypeSimpleType
+        with pushd_popd(self.workspace.directory), SilencedStreams(False, True):
+            return page_from_file(page_file)
 
     def resolve_image(self, image_file: OcrdFile) -> Image:
         with pushd_popd(self.workspace.directory):
             pil_image = Image.open(self.workspace.download_file(image_file).local_filename)
-            pil_image.load()
+            # pil_image.load()
             return pil_image
 
     @check_editable
diff --git a/ocrd_browser/model/page.py b/ocrd_browser/model/page.py
index d66810c..70954fa 100644
--- a/ocrd_browser/model/page.py
+++ b/ocrd_browser/model/page.py
@@ -1,21 +1,48 @@
-from ocrd_models import OcrdFile
-from ocrd_models.ocrd_page_generateds import PcGtsType, PageType, MetadataType
+from typing import List, Optional, Any, Tuple, Dict, Union, cast, Set
+
 from PIL.Image import Image
-from typing import List, Optional
+from ocrd import Workspace
+from ocrd_models import OcrdFile, OcrdExif
+from ocrd_models.ocrd_page import PcGtsType, PageType, MetadataType
+from ocrd_models.constants import NAMESPACES
+from ocrd_utils import MIMETYPE_PAGE, getLogger, pushd_popd
+
+from lxml.etree import ElementBase as Element
 
 
 class Page:
-    def __init__(self, id_: str, file: OcrdFile, pc_gts: Optional[PcGtsType], image_files: List[OcrdFile], images: List[Image]):
-        self.id: str = id_
-        self.file: OcrdFile = file
-        self.pc_gts: PcGtsType = pc_gts
+    def __init__(self, id_: str, file: OcrdFile, pc_gts: Optional[PcGtsType], image_files: List[OcrdFile],
+                 images: List[Image]):
+        self._id: str = id_
+        self._file: OcrdFile = file
+        self._pc_gts: PcGtsType = pc_gts
         # due to AlternativeImage on all hierarchy levels,
         # a physical page can have multiple images or none;
         # if it has none itself, a single image representing the page
         # is extracted from the original image and the annotation
         # of the top level
-        self.image_files: List[OcrdFile] = image_files
-        self.images: List[Image] = images
+        self._image_files: List[OcrdFile] = image_files
+        self._images: List[Image] = images
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @property
+    def file(self) -> OcrdFile:
+        return self._file
+
+    @property
+    def pc_gts(self) -> PcGtsType:
+        return self._pc_gts
+
+    @property
+    def images(self) -> List[Image]:
+        return self._images
+
+    @property
+    def image_files(self) -> List[OcrdFile]:
+        return self._image_files
 
     @property
     def page(self) -> PageType:
@@ -24,3 +51,154 @@ def page(self) -> PageType:
     @property
     def meta(self) -> MetadataType:
         return self.pc_gts.get_Metadata()
+
+    def xpath(self, xpath: str) -> List[Element]:
+        return cast(List[Element], self.xml_root.xpath(xpath, namespaces=NAMESPACES))
+
+    @property
+    def xml_root(self) -> Element:
+        if self.pc_gts.gds_elementtree_node_ is None:
+            from ocrd_models.constants import NAMESPACES
+            from ocrd_models.ocrd_page_generateds import parsexmlstring_
+            from io import StringIO
+            sio = StringIO()
+
+            self.pc_gts.export(
+                outfile=sio,
+                level=0,
+                name_='PcGts',
+                namespaceprefix_='pc:',
+                namespacedef_='xmlns:pc="%s" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="%s %s/pagecontent.xsd"' % (
+                    NAMESPACES['page'],
+                    NAMESPACES['page'],
+                    NAMESPACES['page']
+                ))
+            self.pc_gts.gds_elementtree_node_ = parsexmlstring_(sio.getvalue())  # pylint: disable=undefined-variable
+
+        return self.pc_gts.gds_elementtree_node_
+
+
+class LazyPage(Page):
+    def __init__(self, document: Any, id_: str, file_group: str):
+        self.document = document
+        self._id = id_
+        self._pc_gts = None
+        self.file_group = file_group
+
+    @property
+    def images(self) -> List[Image]:
+        return [self.document.resolve_image(f) for f in self.image_files]
+
+    @property
+    def image_files(self) -> List[OcrdFile]:
+        return self.get_files("//image/.*")
+
+    def get_files(self, mimetype: str = MIMETYPE_PAGE) -> List[OcrdFile]:
+        return cast(List[OcrdFile], self.document.files_for_page_id(self.id, self.file_group, mimetype=mimetype))
+
+    @property
+    def page_file(self) -> OcrdFile:
+        page_files = self.get_files(MIMETYPE_PAGE)
+        if len(page_files) > 0:
+            return page_files[0]
+
+    @property
+    def file(self) -> Optional[OcrdFile]:
+        if self.page_file:
+            return self.page_file
+        else:
+            if len(self.image_files) > 0:
+                return next(iter(self.image_files))
+        return None
+
+    @property
+    def pc_gts(self) -> PcGtsType:
+        if self._pc_gts is None:
+            if self.page_file:
+                self._pc_gts = self.document.page_for_file(self.page_file)
+            else:
+                image_files = self.image_files
+                if len(image_files) > 0:
+                    self._pc_gts = self.document.page_for_file(image_files[0])
+        return self._pc_gts
+
+    def get_image(self, feature_selector: Union[str, Set[str]] = '', feature_filter: Union[str, Set[str]] = '') -> Tuple[Image, Dict[str, Any], OcrdExif]:
+        log = getLogger('ocrd_browser.model.page.LazyPage.get_image')
+        feature_selector = feature_selector if isinstance(feature_selector, str) else ','.join(sorted(feature_selector))
+        feature_filter = feature_filter if isinstance(feature_filter, str) else ','.join(sorted(feature_filter))
+        ws: Workspace = self.document.workspace
+        try:
+            with pushd_popd(ws.directory):
+                page_image, page_coords, page_image_info = ws.image_from_page(self.page, self.id, transparency=True, feature_selector=feature_selector, feature_filter=feature_filter)
+        except Exception as e:
+            log.exception(e)
+            page_image, page_coords, page_image_info = None, None, None
+
+        return page_image, page_coords, page_image_info
+
+    """
+    ViewImages.redraw
+        images
+        image_files[0]
+        image_files[i]
+        pc_gts.gds_elementtree_node_....
+        id
+
+
+             for i, img in enumerate(page.images if page else [None]):
+                ...
+                        if page.image_files[0] == page.file:
+                            # PAGE-XML was created from the (first) image file directly
+                            image.set_tooltip_text(page.id)
+                        else:
+                            img_file = page.image_files[i]
+                            # get segment ID for AlternativeImage as tooltip
+                            img_id = page.pc_gts.gds_elementtree_node_.xpath('//page:AlternativeImage[@filename="{}"]/../@id'.format(img_file.local_filename),namespaces=NS)
+                            if img_id:
+                                image.set_tooltip_text(page.id + ':' + img_id[0])
+                                ...
+    ViewHtml.reload
+        files = self.document.files_for_page_id(self.page_id, self.use_file_group, mimetype='text/html')
+        self.current = Page(self.page_id, files[0], None, [], [])
+
+    ViewHtml.redraw
+        file
+
+        self.web_view.load_uri('file://' + str(self.document.path(self.current.file.local_filename)))
+
+    ViewDiff.redraw
+        pc_gts
+                text = self.get_page_text(self.current.pc_gts)
+
+    ViewPage.rescale
+        images[0]
+                img = self.current.images[0]
+
+    ViewText.redraw
+        pc_gts
+                regions = self.current.pc_gts.get_Page().get_AllRegions(classes=['Text'], order='reading-order')
+
+    ViewXml.open_jpageviewer
+        file
+                if self.current.file and self.current.file.mimetype == MIMETYPE_PAGE:
+
+    ViewXml.redraw
+        file
+        pc_gts
+            if self.current.file and self.current.file.mimetype == MIMETYPE_PAGE:
+                with self.document.path(self.current.file).open('r') as f:
+                    text = f.read()
+            else:
+                text = to_xml(self.current.pc_gts)
+
+    DocumentTestCase.test_page_for_id_with_multiple_images_for_page_and_fileGrp
+            with self.assertLogs('ocrd_browser.model.document', level='WARNING') as log_watch:
+                page = doc.page_for_id('PHYS_0017', 'OCR-D-IMG-CLIP')
+            self.assertIsInstance(page, Page)
+
+    self.id: str = id_
+    self.file: OcrdFile = file
+    self.pc_gts: PcGtsType = pc_gts
+    self.image_files: List[OcrdFile] = image_files
+    self.images: List[Image] = images
+    """
diff --git a/ocrd_browser/model/page_xml_renderer.py b/ocrd_browser/model/page_xml_renderer.py
new file mode 100644
index 0000000..2b349d2
--- /dev/null
+++ b/ocrd_browser/model/page_xml_renderer.py
@@ -0,0 +1,478 @@
+"""
+Page-XML rendering object
+
+This is heavily based on ocrd_segment.extract_pages (https://github.com/OCR-D/ocrd_segment/blob/master/ocrd_segment/extract_pages.py)
+"""
+from math import sin, cos, radians, inf
+from enum import IntFlag
+from typing import Optional, Dict, Any, Union, List, Iterator, Tuple, Type, cast
+from collections import defaultdict
+from logging import Logger
+
+from functools import lru_cache as memoized
+
+from PIL import ImageDraw, Image
+
+from ocrd_models.ocrd_page import PcGtsType, PageType, BorderType, PrintSpaceType, RegionType, TextRegionType, TextLineType, WordType, GlyphType, GraphemeType, ChartRegionType, GraphicRegionType
+from ocrd_utils import coordinates_of_segment, getLogger
+
+from shapely.geometry import Polygon, Point
+from shapely.validation import explain_validity
+from shapely import prepared
+
+RegionWithCoords = Union[RegionType, TextLineType, WordType, GlyphType, GraphemeType, PrintSpaceType, BorderType]
+__all__ = ['PageXmlRenderer', 'RegionMap', 'Feature', 'Region']
+
+CLASSES = {
+    '': 'FFFFFF00',
+    'Glyph': '2E8B08FF',
+    'Word': 'B22222FF',
+    'TextLine': '32CD32FF',
+    'Border': 'FFFFFFFF',
+    'PrintSpace': 'CCCCCCFF',
+    'TableRegion': '8B4513FF',
+    'AdvertRegion': '4682B4FF',
+    'ChemRegion': 'FF8C00FF',
+    'MusicRegion': '9400D3FF',
+    'MapRegion': '9ACDD2FF',
+    'TextRegion': '0000FFFF',
+    'TextRegion:paragraph': '0000FFFA',
+    'TextRegion:heading': '0000FFF5',
+    'TextRegion:caption': '0000FFF0',
+    'TextRegion:header': '0000FFEB',
+    'TextRegion:footer': '0000FFE6',
+    'TextRegion:page-number': '0000FFE1',
+    'TextRegion:drop-capital': '0000FFDC',
+    'TextRegion:credit': '0000FFD7',
+    'TextRegion:floating': '0000FFD2',
+    'TextRegion:signature-mark': '0000FFCD',
+    'TextRegion:catch-word': '0000FFC8',
+    'TextRegion:marginalia': '0000FFC3',
+    'TextRegion:footnote': '0000FFBE',
+    'TextRegion:footnote-continued': '0000FFB9',
+    'TextRegion:endnote': '0000FFB4',
+    'TextRegion:TOC-entry': '0000FFAF',
+    'TextRegion:list-label': '0000FFA5',
+    'TextRegion:other': '0000FFA0',
+    'ChartRegion': '800080FF',
+    'ChartRegion:bar': '800080FA',
+    'ChartRegion:line': '800080F5',
+    'ChartRegion:pie': '800080F0',
+    'ChartRegion:scatter': '800080EB',
+    'ChartRegion:surface': '800080E6',
+    'ChartRegion:other': '800080E1',
+    'GraphicRegion': '008000FF',
+    'GraphicRegion:logo': '008000FA',
+    'GraphicRegion:letterhead': '008000F0',
+    'GraphicRegion:decoration': '008000EB',
+    'GraphicRegion:frame': '008000E6',
+    'GraphicRegion:handwritten-annotation': '008000E1',
+    'GraphicRegion:stamp': '008000DC',
+    'GraphicRegion:signature': '008000D7',
+    'GraphicRegion:barcode': '008000D2',
+    'GraphicRegion:paper-grow': '008000CD',
+    'GraphicRegion:punch-hole': '008000C8',
+    'GraphicRegion:other': '008000C3',
+    'ImageRegion': '00CED1FF',
+    'LineDrawingRegion': 'B8860BFF',
+    'MathsRegion': '00BFFFFF',
+    'NoiseRegion': 'FF0000FF',
+    'SeparatorRegion': 'FF00FFFF',
+    'UnknownRegion': '646464FF',
+    'CustomRegion': '637C81FF'
+}
+
+
+class Feature(IntFlag):
+    IMAGE = 1
+    BORDER = 2
+    PRINT_SPACE = 4
+    ORDER = 8
+    REGIONS = 16
+    LINES = 32
+    WORDS = 64
+    GLYPHS = 128
+    GRAPHEMES = 256
+    WARNINGS = 512
+
+    DEFAULT = 1 | 2 | 16 | 32
+
+    def should_render(self, region_ds: RegionWithCoords) -> bool:
+        lookup = {
+            self.BORDER: BorderType,
+            self.PRINT_SPACE: PrintSpaceType,
+            self.REGIONS: RegionType,
+            self.LINES: TextLineType,
+            self.WORDS: WordType,
+            self.GLYPHS: GlyphType,
+            # self.GRAPHEMES: GraphemeType
+        }
+        for feature, region_type in lookup.items():
+            if feature & self and isinstance(region_ds, region_type):
+                return True
+        return False
+
+
+class Region:
+    """
+    A wrapper around all types of Page-XML regions and their polygon with convenience methods
+    """
+    def __init__(self, region: RegionWithCoords) -> None:
+        self.region = region
+        self._poly: Optional[Polygon] = None
+        self._prep_poly: Optional[prepared.PreparedGeometry] = None
+        self.warnings: List[str] = []
+
+    @property
+    def poly(self) -> Polygon:
+        return self._poly
+
+    @poly.setter
+    def poly(self, poly: Polygon) -> None:
+        self._poly = poly
+        self._prep_poly = prepared.prep(self._poly)
+
+    def contains(self, p: Point) -> bool:
+        return self._prep_poly and cast(bool, self._prep_poly.contains(p))
+
+    @property
+    def id(self) -> str:
+        if hasattr(self.region, 'id'):
+            return str(self.region.id)
+        elif hasattr(self.region, 'pcGtsId'):
+            return str(self.region.pcGtsId)
+        elif hasattr(self.region, 'imageFilename'):
+            return str(self.region.imageFilename)
+        else:
+            return ''
+
+    @property
+    def region_type(self) -> str:
+        return self.base_type + (':' + self.region_subtype if self.region_subtype else '')
+
+    @property
+    def base_type(self) -> str:
+        return cast(str, self.region.__class__.__name__[:-4])
+
+    @property
+    def region_subtype(self) -> str:
+        return cast(str, self.region.get_type()) if isinstance(self.region, (TextRegionType, ChartRegionType, GraphicRegionType)) else ''
+
+    @property
+    def text(self) -> str:
+        if isinstance(self.region, (TextRegionType, TextLineType, WordType, GlyphType)):
+            if self.region.get_TextEquiv() and self.region.get_TextEquiv()[0].Unicode:
+                return cast(str, self.region.get_TextEquiv()[0].Unicode)
+        return ''
+
+    @property
+    def parent(self) -> Optional['Region']:
+        return Region(self.region.parent_object_) if hasattr(self.region, 'parent_object_') and self.region.parent_object_ else None
+
+    @memoized(maxsize=1)
+    def depth(self) -> int:
+        return len(self.breadcrumbs())
+
+    @memoized(maxsize=1)
+    def breadcrumbs(self) -> List['Region']:
+        """
+        Traverses region up to the root (PcGts) element
+        """
+        breadcrumbs: List[Region] = []
+        r = self
+        while r:
+            breadcrumbs.append(r)
+            r = r.parent
+        return list(breadcrumbs)
+
+    def __str__(self) -> str:
+        return '{:s}{:s}'.format(self.region_type, '#' + self.id)
+
+    def __hash__(self) -> int:
+        return id(self.region)
+
+    def __eq__(self, other: Any) -> bool:
+        return isinstance(other, self.__class__) and self.region is other.region
+
+
+class RegionBase:
+    def __init__(self) -> None:
+        self.children: List['RegionNode'] = []
+
+    def append(self, node: 'RegionNode') -> None:
+        self.children.append(node)
+
+    def find_region(self, x: float, y: float, ignore_regions: Optional[List[Type[RegionWithCoords]]] = None) -> Optional[Region]:
+        """
+        Finds deepest region at x,y
+        """
+        ignore_regions = ignore_regions if ignore_regions is not None else [BorderType, PrintSpaceType]
+
+        def filter_regions(n: Region) -> bool:
+            return type(n.region) not in ignore_regions
+
+        p = Point(x, y)
+
+        regions = list(filter(filter_regions, self.find_regions(p)))
+        return regions[-1] if regions else None
+
+    def find_regions(self, p: Point) -> List[Region]:
+        regions: List[Region] = []
+        for node in self.children:
+            if node.region.contains(p):
+                regions.append(node.region)
+                regions.extend(node.find_regions(p))
+        return regions
+
+
+class RegionNode(RegionBase):
+    def __init__(self, region: Region):
+        super().__init__()
+        self.region = region
+
+    def __str__(self) -> str:
+        return '{}'.format(self.region)
+
+
+class RegionMap(RegionBase):
+    """
+    Builds a tree of regions based on their Page-XML nesting.
+    """
+    def __init__(self) -> None:
+        super().__init__()
+        self.nodes_by_region: Dict[Region, RegionNode] = {}
+        self.region_by_id: Dict[str, Region] = {}
+
+    def refetch(self, r: Region) -> Optional[Region]:
+        return self.get(r.id) if r else None
+
+    def get(self, id_: str) -> Optional[Region]:
+        return self.region_by_id.get(id_, None)
+
+    def append(self, node: RegionNode) -> None:
+        """
+        appends a RegionNode to the tree
+        IMPORTANT: this will only work if the parent node already exists, so the calls have to be in breadth-first or depth-first pre-order order.
+        The Operations class will iterate in breadth-first order
+        """
+        self.nodes_by_region[node.region] = node
+        if node.region.id:
+            self.region_by_id[node.region.id] = node.region
+
+        if node.region.parent in self.nodes_by_region:
+            parent_region = self.nodes_by_region[node.region.parent]
+            parent_region.append(node)
+        else:
+            self.children.append(node)
+
+
+class Operation:
+    def __init__(self, region: Region, fill: str, outline: str):
+        self.region = region
+        self.fill = fill
+        self.outline = outline
+
+    def paint(self, draw: ImageDraw.Draw, regions: RegionMap) -> None:
+        pass
+
+    @property
+    def depth(self) -> int:
+        return self.region.depth()
+
+
+class PolygonOperation(Operation):
+
+    def paint(self, draw: ImageDraw.Draw, regions: RegionMap) -> None:
+        xy = list(map(tuple, self.region.poly.exterior.coords[:-1]))
+        draw.polygon(xy, self.fill, self.outline)
+        regions.append(RegionNode(self.region))
+
+
+class Operations:
+    """
+    Operations is a depth-sorted List of Operation objects
+
+    Each depth can be plotted on its own image-layer and will be blended blended with Image.alpha_composite, so
+    Image.alpha_composite will only be called once per layer instead of once per operation
+    """
+
+    def __init__(self) -> None:
+        self.operations: Dict[int, List[Operation]] = defaultdict(list)
+
+    def append(self, op: Operation) -> None:
+        self.operations[op.depth].append(op)
+
+    def layers(self) -> Iterator[Tuple[int, List[Operation]]]:
+        for layer in sorted(self.operations, reverse=False):
+            yield layer, self.operations[layer]
+
+    def paint(self, canvas: Image.Image) -> Tuple[Image.Image, RegionMap]:
+        """
+        Paints the operations on canvas and fills the RegionMap accordingly
+        """
+        regions = RegionMap()
+        for depth, operations in self.layers():
+            layer = Image.new(mode='RGBA', size=canvas.size, color='#FFFFFF00')
+            draw = ImageDraw.Draw(layer)
+            for operation in operations:
+                operation.paint(draw, regions)
+            canvas.alpha_composite(layer)
+        self.operations.clear()
+        return canvas, regions
+
+
+class RegionFactory:
+    def __init__(self, coords: Dict[str, Any], page_id: str = '<unknown>', logger: Logger = None):
+        self.coords = coords
+        self.page_id = page_id
+        self.logger = logger or getLogger(self.__class__.__module__ + '.' + self.__class__.__name__)
+
+    def create(self, region_ds: RegionWithCoords) -> Optional[Region]:
+        if not region_ds:
+            return None
+
+        region = Region(region_ds)
+        coords = coordinates_of_segment(region_ds, None, self.coords)
+
+        warnings = []
+
+        try:
+            poly = Polygon(coords)
+        except ValueError as err:
+            self.logger.error('Page "%s" @ %s %s', self.page_id, str(region), str(err))
+            return None
+
+        if not poly.is_valid:
+            warning = explain_validity(poly)
+            poly, error = self.make_valid(poly)
+            if not poly.is_valid:
+                self.logger.error('Page "%s" @ %s %s', self.page_id, str(region), str(warning))
+                return None
+            else:
+                warnings.append('{} fixed with an error of {:.3%}'.format(warning, error))
+
+        if poly.length < 4:
+            warnings.append(str('has too few points'))
+
+        if poly.is_empty or not poly.area > 0:
+            self.logger.error('Page "%s" @ %s %s', self.page_id, str(region), 'is empty')
+            return None
+
+        if poly.bounds[0] < 0 or poly.bounds[1] < 0:
+            warnings.append('is negative')
+
+        if warnings:
+            self.logger.warning('Page "%s" @ %s %s', self.page_id, str(region), ' | '.join(warnings))
+
+        region.poly = poly
+        region.warnings = warnings
+
+        return region
+
+    @staticmethod
+    def make_valid(polygon: Polygon) -> Tuple[Polygon, float]:
+        """Ensures shapely.geometry.Polygon object is valid by repeated simplification"""
+        tolerance = 1
+        for split in range(1, len(polygon.exterior.coords) - 1):
+            if polygon.is_valid or polygon.simplify(polygon.area).is_valid:
+                break
+            # simplification may not be possible (at all) due to ordering
+            # in that case, try another starting point
+            polygon = Polygon(polygon.exterior.coords[-split:] + polygon.exterior.coords[:-split])
+        for tolerance in range(1, int(polygon.area)):
+            if polygon.is_valid:
+                break
+            # simplification may require a larger tolerance
+            polygon = polygon.simplify(tolerance)
+
+        a = polygon.area
+        return polygon, tolerance / a if a > 0 else inf
+
+
+class PageXmlRenderer:
+
+    def __init__(self, canvas: Image.Image, coords: Dict[str, Any], page_id: str = '<unknown>',
+                 features: Optional[Feature] = None, colors: Optional[Dict[str, str]] = None, logger: Logger = None):
+        self.features = features or Feature.DEFAULT
+
+        if self.features & Feature.IMAGE:
+            self.canvas = canvas.convert('RGBA')
+        else:
+            self.canvas = Image.new(mode='RGBA', size=canvas.size, color='#FFFFFF00')
+
+        self.region_factory = RegionFactory(coords, page_id, logger)
+        print(coords)
+
+        self.colors: Dict[str, str] = defaultdict(lambda: 'FF0000FF')
+        self.colors.update(colors or CLASSES)
+
+        self.operations = Operations()
+        self.order: List[Point] = []
+
+    def render_all(self, pc_gts: PcGtsType) -> None:
+        page: PageType = pc_gts.get_Page()
+        self.render_type(page.get_PrintSpace())
+        self.render_type(page.get_Border())
+        for region_ds in page.get_AllRegions(order='reading-order'):
+            self.render_type(region_ds)
+
+        if self.features & Feature.ORDER:
+            for region_ds in page.get_AllRegions(classes=['Text'], order='reading-order'):
+                region = self.region_factory.create(region_ds)
+                self.order.append(region.poly.centroid)
+
+    def get_result(self) -> Tuple[Image.Image, RegionMap]:
+        canvas, regions = self.operations.paint(self.canvas.copy())
+        self.draw_order(canvas)
+        return canvas, regions
+
+    def draw_order(self, canvas: Image.Image) -> None:
+        if self.order and len(self.order) > 1:
+            last_point = None
+            draw = ImageDraw.Draw(canvas)
+            for p in self.order:
+                point = p.coords[0]
+                if last_point:
+                    self.draw_arrow(draw, last_point, point)  # type: ignore[unreachable]
+                last_point = point
+
+            self.order = []
+
+    @staticmethod
+    def draw_arrow(draw: ImageDraw.Draw, p0: Tuple[float, float], p1: Tuple[float, float], color: str = '#FF0000FF', arrow_size: float = 30.0, angle_deg: float = 30.0, line_width: int = 3) -> None:
+        angle = radians(180.0 - angle_deg)
+        c, s = cos(angle), sin(angle)
+        d = p1[0] - p0[0], p1[1] - p0[1]
+        left = d[0] * c - d[1] * s, d[0] * s + d[1] * c
+        right = d[0] * c + d[1] * s, -d[0] * s + d[1] * c
+        lf = arrow_size / (d[0] ** 2 + d[1] ** 2) ** 0.5
+
+        draw.line([p0, p1], fill=color, width=line_width)
+        draw.ellipse((p1[0] - 5, p1[1] - 5, p1[0] + 5, p1[1] + 5), fill=color)
+        draw.line([(p1[0] + lf * left[0], p1[1] + lf * left[1]), p1], fill=color, width=line_width)
+        draw.line([(p1[0] + lf * right[0], p1[1] + lf * right[1]), p1], fill=color, width=line_width)
+
+    def render_type(self, region_ds: RegionWithCoords) -> None:
+        if self.features.should_render(region_ds):
+            region = self.region_factory.create(region_ds)
+            if region:
+                color = self.colors[region.region_type]
+                if self.features & Feature.WARNINGS and region.warnings:
+                    op = PolygonOperation(region, '#FF00003E', '#FF000076')
+                else:
+                    op = PolygonOperation(region, '#' + color[:6] + '1E', '#' + color[:6] + '96')
+                self.operations.append(op)
+
+        if isinstance(region_ds, TextRegionType):
+            self.render_text_region(region_ds)
+
+    def render_text_region(self, text_region: TextRegionType) -> None:
+        line: TextLineType
+        word: WordType
+        glyph: GlyphType
+        for line in text_region.get_TextLine():
+            self.render_type(line)
+            for word in line.get_Word():
+                self.render_type(word)
+                for glyph in word.get_Glyph():
+                    self.render_type(glyph)
diff --git a/ocrd_browser/resources/main-window.ui b/ocrd_browser/resources/main-window.ui
index 8f06797..b9ed917 100644
--- a/ocrd_browser/resources/main-window.ui
+++ b/ocrd_browser/resources/main-window.ui
@@ -139,7 +139,6 @@ THE SOFTWARE.
           <object class="GtkMenuButton" id="view_menu_button">
             <property name="visible">True</property>
             <property name="can_focus">True</property>
-            <property name="focus_on_click">False</property>
             <property name="receives_default">True</property>
             <property name="tooltip_text" translatable="yes">Create new views</property>
             <property name="draw_indicator">True</property>
@@ -164,7 +163,6 @@ THE SOFTWARE.
     <child>
       <object class="GtkPaned" id="panes">
         <property name="visible">True</property>
-        <property name="can_focus">True</property>
         <property name="position">255</property>
         <child>
           <object class="GtkBox">
@@ -174,7 +172,6 @@ THE SOFTWARE.
             <child>
               <object class="GtkScrolledWindow" id="page_list_scroller">
                 <property name="visible">True</property>
-                <property name="can_focus">True</property>
                 <property name="hscrollbar_policy">never</property>
                 <property name="shadow_type">in</property>
                 <child>
@@ -308,9 +305,6 @@ THE SOFTWARE.
         <child>
           <object class="GtkBox" id="view_container">
             <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="can_default">True</property>
-            <property name="receives_default">True</property>
             <property name="homogeneous">True</property>
             <property name="name">view_container</property>
             <child>
diff --git a/ocrd_browser/ui/icon_store.py b/ocrd_browser/ui/icon_store.py
index 7b27fb6..9651101 100644
--- a/ocrd_browser/ui/icon_store.py
+++ b/ocrd_browser/ui/icon_store.py
@@ -25,7 +25,7 @@ def __init__(self, *column_types: type, init_row: RowInitCallback, load_row: Row
     def start_loading(self) -> None:
         self.futures = {}
         self.submit_all()
-        GLib.idle_add(self._collect_workers().__next__, priority=GLib.PRIORITY_DEFAULT_IDLE)
+        GLib.timeout_add(10, self._collect_workers().__next__, priority=GLib.PRIORITY_LOW)
 
     def submit_all(self) -> bool:
         pool = ThreadPoolExecutor()
@@ -80,5 +80,5 @@ def _collect_workers(self) -> Iterator[bool]:
                     row[-1] = self.hash_row(row)
                 yield True
         # Futures are finished for now, check back every 50ms if there is something new
-        GLib.timeout_add(50, self._collect_workers().__next__, priority=GLib.PRIORITY_DEFAULT_IDLE)
+        GLib.timeout_add(50, self._collect_workers().__next__, priority=GLib.PRIORITY_LOW)
         yield False
diff --git a/ocrd_browser/ui/page_browser.py b/ocrd_browser/ui/page_browser.py
index 78a7f6c..2342c3f 100644
--- a/ocrd_browser/ui/page_browser.py
+++ b/ocrd_browser/ui/page_browser.py
@@ -51,12 +51,13 @@ def setup_ui(self) -> None:
         text_renderers = [cell for cell in self.get_cells() if isinstance(cell, Gtk.CellRendererText)]
         text_renderer: Gtk.CellRendererText = text_renderers[0]
         text_renderer.props.ellipsize = Pango.EllipsizeMode.MIDDLE
-        self.connect('button-release-event', self.button_pressed)
+        self.connect('button-press-event', self.button_pressed)
 
     def button_pressed(self, _sender: Gtk.Widget, event: Gdk.EventButton) -> None:
-        if event.get_button()[1] == 3:
-            path, renderer = self.get_item_at_pos(*event.get_coords())
-            self.emit('on_context_menu', event, path, renderer)
+        if event.button == Gdk.BUTTON_SECONDARY and event.type == Gdk.EventType.BUTTON_PRESS:
+            result = self.get_item_at_pos(*event.get_coords())
+            if result:
+                self.emit('on_context_menu', event, result[0], result[1])
 
     @GObject.Signal(arg_types=[object, object, object])
     def on_context_menu(self, event: Gdk.EventButton, path: Gtk.TreePath, _renderer: Gtk.CellRenderer) -> None:
diff --git a/ocrd_browser/ui/window.py b/ocrd_browser/ui/window.py
index 32b59d9..12be316 100644
--- a/ocrd_browser/ui/window.py
+++ b/ocrd_browser/ui/window.py
@@ -2,7 +2,7 @@
 from ocrd_models import OcrdFile
 
 from ocrd_browser.model import Document
-from ocrd_browser.view import ViewRegistry, ViewImages
+from ocrd_browser.view import ViewRegistry, ViewPage
 from ocrd_browser.util.gtk import ActionRegistry
 from .dialogs import SaveDialog, SaveChangesDialog
 from .page_browser import PagePreviewList
@@ -60,7 +60,8 @@ def __init__(self, **kwargs: Any):
             menu_item.set_detailed_action_name('win.create_view("{}")'.format(id_))
             self.view_menu_box.pack_start(menu_item, True, True, 0)
 
-        self.view_manager.set_root_view(ViewImages)
+        self.view_manager.set_root_view(ViewPage)
+        # self.view_manager.split(None, ViewPage, False)
 
         self.update_ui()
 
@@ -90,8 +91,8 @@ def _open(self, uri: str) -> None:
         self.document = Document.load(uri, emitter=self.emit)
         self.page_list.set_document(self.document)
 
-        self.update_ui()
         self.view_manager.set_document(self.document)
+        self.update_ui()
 
         if len(self.document.page_ids):
             self.on_page_activated(None, self.document.page_ids[0])
@@ -117,7 +118,6 @@ def on_pages_selected(self, _sender: Optional[Gtk.Widget], page_ids: List[str])
 
     @GObject.Signal(arg_types=[object])
     def pages_selected(self, page_ids: List[str]) -> None:
-        print(page_ids)
         pass
 
     @GObject.Signal(arg_types=[str, object])
@@ -203,7 +203,11 @@ def on_replace_view(self, _a: Gio.SimpleAction, arguments: GLib.Variant) -> None
         self.view_manager.replace(replace_view, new_view_type)
 
     def on_close_view(self, _action: Gio.SimpleAction, view_name: GLib.Variant) -> None:
-        self.view_manager.close(view_name.get_string())
+        try:
+            self.view_manager.close(view_name.get_string())
+        except ValueError:
+            # Tried to remove last view
+            pass
 
     def on_split_view(self, _action: Gio.SimpleAction, arguments: GLib.Variant) -> None:
         (split_view, new_view_name, horizontal) = arguments
diff --git a/ocrd_browser/util/file_groups.py b/ocrd_browser/util/file_groups.py
new file mode 100644
index 0000000..da6bdde
--- /dev/null
+++ b/ocrd_browser/util/file_groups.py
@@ -0,0 +1,35 @@
+import re
+from collections import Counter
+from typing import List, Tuple, Optional, Counter as CounterType
+
+
+def weight_match(s: str, preferreds: Optional[List[str]] = None) -> float:
+    """
+    Weights how good a string matches a list of regular expressions
+    """
+    weight = 0.0
+    if preferreds:
+        ln = float(len(preferreds))
+        for i, preferred in enumerate(preferreds):
+            if re.fullmatch(preferred, s):
+                # prefer matches earlier in the list
+                weight += (ln - i) / ln
+                # break or no break???
+    return weight
+
+
+def best_file_group(file_groups_and_mimetypes: List[Tuple[str, str]], preferred_groups: Optional[List[str]] = None, preferred_mimetypes: Optional[List[str]] = None, cutoff: float = -9999) -> Optional[Tuple[str, str]]:
+    file_groups: CounterType[Tuple[str, str]] = Counter()
+    for file_group_and_mimetype in file_groups_and_mimetypes:
+        file_group, mimetype = file_group_and_mimetype
+        file_groups[file_group_and_mimetype] += weight_match(file_group, preferred_groups)  # type: ignore[assignment]
+        file_groups[file_group_and_mimetype] += weight_match(mimetype, preferred_mimetypes)  # type: ignore[assignment]
+        file_groups[file_group_and_mimetype] -= len(file_group) * 0.0001  # type: ignore[assignment]
+    if file_groups:
+        [(file_group_and_mimetype, score)] = file_groups.most_common(1)
+        if score > cutoff:
+            return file_group_and_mimetype
+        else:
+            return None
+    else:
+        return None
diff --git a/ocrd_browser/util/gtk.py b/ocrd_browser/util/gtk.py
index 96c6fed..a34c5ae 100644
--- a/ocrd_browser/util/gtk.py
+++ b/ocrd_browser/util/gtk.py
@@ -1,13 +1,26 @@
-from typing import Callable, Dict, Optional
+from gi.repository import Gio, GLib, Gtk
 
-from gi.repository import Gio, GLib
+from typing import Callable, Dict, Optional, Set, Any
 
-ActionCallback = Optional[Callable[[Gio.SimpleAction, str], None]]
+ActionCallback = Optional[Callable[[Gio.SimpleAction, Any], None]]
+
+
+def print_event(*args: Any, **kwargs: Any) -> None:
+    """
+    Print out all arguments for event exploration, usage: gtk_widget.connect(gtk_event_name, print_event)
+    """
+    def nice(e: Any) -> Any:
+        if isinstance(e, Gtk.Widget):
+            return e.get_path().to_string()
+        else:
+            return e
+
+    print(list(map(nice, args)), kwargs)
 
 
 class ActionRegistry:
-    def __init__(self, for_widget: Gio.ActionMap):
-        self.for_widget = for_widget
+    def __init__(self, for_widget: Optional[Gio.ActionMap] = None):
+        self.for_widget = for_widget if for_widget else Gio.SimpleActionGroup()
         self.actions: Dict[str, Gio.SimpleAction] = {}
 
     def create(self, name: str, callback: ActionCallback = None,
@@ -24,3 +37,68 @@ def create(self, name: str, callback: ActionCallback = None,
 
     def __getitem__(self, item: str) -> Gio.SimpleAction:
         return self.actions[item]
+
+
+class Callback:
+    """
+    Wrapper around a callback function with equality by callback function, to be used by WhenIdle
+    """
+    def __init__(self, callback: Callable, *args: Any, **kwargs: Any):  # type: ignore[type-arg]
+        self.priority = kwargs.pop('priority', 100)
+        self.callback = callback
+        self.args = args
+        self.kwargs = kwargs
+
+    def __hash__(self) -> int:
+        return hash(self.callback)
+
+    def __eq__(self, other: object) -> bool:
+        return isinstance(other, Callback) and self.callback == other.callback
+
+    def __call__(self) -> None:
+        self.callback(*self.args, **self.kwargs)
+
+    def __str__(self) -> str:
+        return '{:03d} {!r}({!r}, {!r})'.format(self.priority, self.callback, self.args, self.kwargs)
+
+
+class WhenIdle:
+    """
+    Debouncing wrapper around GLib.idle_add (or another call-later-mechanism) that overwrites prior calls to the same callback
+
+    Also supports priorities, lower priorities get called first
+
+    Usage: see WhenIdle.call
+    """
+    _instance: 'WhenIdle' = None
+
+    def __init__(self, runner_callback: Callable):  # type: ignore[type-arg]
+        self._runner_callback = runner_callback
+        self._callbacks: Set[Callback] = set()
+
+    @classmethod
+    def instance(cls) -> 'WhenIdle':
+        if cls._instance is None:
+            cls._instance = cls(GLib.idle_add)
+        return cls._instance
+
+    @classmethod
+    def call(cls, callback: Callable, *args: Any, **kwargs: Any) -> None:   # type: ignore[type-arg]
+        """
+        Debouncing wrapper around GLib.idle_add
+        Usage:
+        > WhenIdle.call(self.redraw, 5, force = True, priority=10)
+        will call self.redraw(5, force=True) with priority 10
+        """
+        cls.instance().add(Callback(callback, *args, **kwargs))
+
+    def add(self, callback: Callback) -> None:
+        self._callbacks.add(callback)
+        self._runner_callback(self._run)
+
+    def _run(self) -> None:
+        if self._callbacks:
+            callback = sorted(self._callbacks, key=lambda c: int(c.priority))[0]
+            callback()
+            self._callbacks.remove(callback)
+            self._runner_callback(self._run)
diff --git a/ocrd_browser/view/__init__.py b/ocrd_browser/view/__init__.py
index 12a5463..f83bd31 100644
--- a/ocrd_browser/view/__init__.py
+++ b/ocrd_browser/view/__init__.py
@@ -6,6 +6,7 @@
 from .xml import ViewXml
 from .empty import ViewEmpty
 from .diff import ViewDiff
+from .page import ViewPage
 
 
-__all__ = ['View', 'ViewRegistry', 'ViewImages', 'ViewText', 'ViewXml', 'ViewHtml', 'ViewEmpty', 'ViewDiff']
+__all__ = ['View', 'ViewRegistry', 'ViewImages', 'ViewText', 'ViewXml', 'ViewHtml', 'ViewEmpty', 'ViewDiff', 'ViewPage']
diff --git a/ocrd_browser/view/base.py b/ocrd_browser/view/base.py
index 019babc..5893ac8 100644
--- a/ocrd_browser/view/base.py
+++ b/ocrd_browser/view/base.py
@@ -1,10 +1,14 @@
+from math import log
+
 from gi.repository import Gtk, Pango, GObject
 
 from typing import List, Tuple, Any, Optional, Dict, cast
 
 from enum import Enum
+
 from ocrd_utils.constants import MIMETYPE_PAGE, MIME_TO_EXT
 from ocrd_browser.model import Document, Page
+from ocrd_browser.util.gtk import WhenIdle
 
 
 class Configurator(Gtk.Widget):
@@ -16,11 +20,15 @@ def __init__(self, **kwargs: Any) -> None:
     def set_document(self, document: Document) -> None:
         self.document = document
 
+    def set_page(self, page: Page) -> None:
+        pass
+
     def set_value(self, value: Any) -> None:
         raise NotImplementedError('You have to override set_value')
 
 
 class View:
+    # TODO: Views should announce which mimetype they can handle and be only available if it there is matching mimetype in Document, also they should announce if they can handle a certain Page
     # noinspection PyTypeChecker
     def __init__(self, name: str, window: Gtk.Window):
         self.name: str = name
@@ -75,15 +83,21 @@ def use_file_group(self) -> str:
         return 'OCR-D-IMG'
 
     def page_activated(self, _sender: Gtk.Widget, page_id: str) -> None:
-        self.page_id = page_id
-        self.reload()
+        if page_id != self.page_id:
+            self.page_id = page_id
+            self.reload()
 
     def pages_selected(self, _sender: Gtk.Widget, page_ids: List[str]) -> None:
         pass
 
     def reload(self) -> None:
-        self.current = self.document.page_for_id(self.page_id, self.use_file_group)
-        self.redraw()
+        if self.page_id:
+            self.current = self.document.page_for_id(self.page_id, self.use_file_group)
+            if self.current:
+                for configurator in self.configurators.values():
+                    configurator.set_page(self.current)
+
+        WhenIdle.call(self.redraw)
 
     def redraw(self) -> None:
         pass
@@ -125,21 +139,20 @@ def changed(self, page_qty: int) -> None:
 class ImageZoomSelector(Gtk.Box, Configurator):
 
     def __init__(self, base: float = 2, step: float = 0.1, min_: float = -4.0, max_: float = 2.0) -> None:
-        super().__init__(visible=True, spacing=3)
+        super().__init__(visible=True, spacing=2)
         self.value = None
 
-        label = Gtk.Label(label='Zoom:', visible=True)
-
         self.base = base
         self.scale = Gtk.SpinButton(visible=True, max_length=5, width_chars=5, max_width_chars=5, numeric=True,
                                     digits=2)
-        self.scale.set_tooltip_text('log{:+.2f} scale factor for viewing images'.format(base))
+        self.scale.set_tooltip_text('Zoom: log{:+.0f} scale factor for viewing images'.format(base))
         # noinspection PyCallByClass,PyArgumentList
         self.scale.set_adjustment(Gtk.Adjustment.new(0.0, min_, max_, step, 0, 0))
         self.scale.set_snap_to_ticks(False)
         self.scale.connect('value-changed', self.value_changed)
 
-        self.pack_start(label, False, True, 0)
+        # label = Gtk.Label(label='Zoom:', visible=True)
+        # self.pack_start(label, False, True, 0)
         self.pack_start(self.scale, False, True, 0)
 
     def get_exp(self) -> float:
@@ -159,17 +172,46 @@ def changed(self, scale: float) -> None:
         self.scale.set_tooltip_text(
             '{:.2%} / log{:.2f} scale factor for viewing images'.format(self.get_exp(), self.base))
 
+    def zoom_by(self, steps: int) -> None:
+        """
+        Zooms in(`steps` > 0) or out(`steps` < 0) by `steps` steps
+        """
+        direction = Gtk.SpinType.STEP_FORWARD if steps > 0 else Gtk.SpinType.STEP_BACKWARD
+        self.scale.spin(direction, abs(steps) * self.scale.get_adjustment().get_step_increment())
+
+    def zoom_to(self, to: str, width_ratio: float, height_ratio: float) -> None:
+        """
+        Set zoom to one of
+            'original': Original Size
+            'width': Fit to width
+            'height': Fit to height
+            'page': Fit to show whole page
+            'viewport': Fit to use whole viewport
+        """
+        lookup = {
+            'original': lambda: 1,
+            'width': lambda: width_ratio,
+            'height': lambda: height_ratio,
+            'page': lambda: min(width_ratio, height_ratio),
+            'viewport': lambda: max(width_ratio, height_ratio)
+        }
+        if to in lookup:
+            ratio = lookup[to]()
+            self.set_value(log(ratio, self.base))
+        else:
+            raise ValueError('to was "{}", but needs to be one of {}'.format(to, ', '.join(lookup.keys())))
+
 
 class FileGroupSelector(Gtk.Box, Configurator):
 
     def __init__(self, filter_: Optional['FileGroupFilter'] = None, show_mime: bool = False, show_ext: bool = True):
-        super().__init__(visible=True, spacing=3)
+        super().__init__(visible=True, spacing=2)
         self.value = None
-        label = Gtk.Label(label='Group:', visible=True)
 
         self.groups = FileGroupComboBox(filter_, show_mime, show_ext)
 
-        self.pack_start(label, False, True, 0)
+        # label = Gtk.Label(label='Group:', visible=True)
+        # self.pack_start(label, False, True, 0)
         self.pack_start(self.groups, False, True, 0)
 
         self.groups.connect('changed', self.combo_box_changed)
@@ -206,7 +248,7 @@ class FileGroupComboBox(Gtk.ComboBox):
     COLUMN_EXT = 3
 
     def __init__(self, filter_: Optional['FileGroupFilter'] = None, show_mime: bool = False, show_ext: bool = True):
-        Gtk.ComboBox.__init__(self, visible=True)
+        Gtk.ComboBox.__init__(self, visible=True, has_tooltip=True, popup_fixed_width=False)
         self.set_model(Gtk.ListStore(str, str, str, str))
         self.filter = filter_
         self.set_id_column(self.COLUMN_ID)
@@ -217,8 +259,6 @@ def __init__(self, filter_: Optional['FileGroupFilter'] = None, show_mime: bool
         if show_ext:
             self.add_renderer(self.COLUMN_EXT)
 
-        self.props.has_tooltip = True
-        self.props.popup_fixed_width = False
         self.connect('query-tooltip', self.set_tooltip)
 
     def set_tooltip(self, _widget: Gtk.Widget, _x: int, _y: int, _keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
@@ -226,7 +266,7 @@ def set_tooltip(self, _widget: Gtk.Widget, _x: int, _y: int, _keyboard_mode: boo
         if len(model) > 0:
             row = self.get_model()[self.get_active()][:]
             phs = {'fileGrp': row[self.COLUMN_GROUP], 'ext': row[self.COLUMN_EXT], 'mime': row[self.COLUMN_MIME]}
-            tooltip.set_text('{fileGrp} (Mime-Type: {mime})'.format(**phs))
+            tooltip.set_text('fileGrp: {fileGrp} (Mime-Type: {mime})'.format(**phs))
             return True
         return False
 
@@ -235,8 +275,7 @@ def set_document(self, document: Document) -> None:
         # self.set_active(0)
 
     def add_renderer(self, column: int, width: int = None) -> Gtk.CellRendererText:
-        renderer = Gtk.CellRendererText()
-        renderer.props.ellipsize = Pango.EllipsizeMode.MIDDLE
+        renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.MIDDLE)
         if width:
             renderer.props.width = width
         self.pack_start(renderer, False)
@@ -267,9 +306,12 @@ def image_filter(model: Gtk.TreeModel, it: Gtk.TreeIter, _data: None) -> bool:
 
     @staticmethod
     def page_filter(model: Gtk.TreeModel, it: Gtk.TreeIter, _data: None) -> bool:
-        # str casts for mypy
         return str(model[it][FileGroupComboBox.COLUMN_MIME]) == str(MIMETYPE_PAGE)
 
+    @staticmethod
+    def xml_filter(model: Gtk.TreeModel, it: Gtk.TreeIter, _data: None) -> bool:
+        return str(model[it][FileGroupComboBox.COLUMN_EXT]) == '.xml'
+
     @staticmethod
     def html_filter(model: Gtk.TreeModel, it: Gtk.TreeIter, _data: None) -> bool:
         # str casts for mypy
@@ -284,6 +326,7 @@ class FileGroupFilter(Enum):
     IMAGE = FileGroupModel.image_filter
     PAGE = FileGroupModel.page_filter
     HTML = FileGroupModel.html_filter
+    XML = FileGroupModel.xml_filter
     ALL = FileGroupModel.all_filter
 
 
@@ -294,6 +337,7 @@ def __init__(self, view_name: str):
         self.set_detailed_action_name('win.close_view("{}")'.format(view_name))
         self.set_relief(Gtk.ReliefStyle.NONE)
         self.set_always_show_image(True)
+        # noinspection PyArgumentList
         self.set_image(Gtk.Image.new_from_icon_name('window-close-symbolic', Gtk.IconSize.SMALL_TOOLBAR))
 
 
@@ -305,4 +349,5 @@ def __init__(self, view_name: str, vertical: bool = True):
         self.set_detailed_action_name('win.split_view(("{}","empty", {}))'.format(view_name, 'true' if vertical else 'false'))
         self.set_relief(Gtk.ReliefStyle.NONE)
         self.set_always_show_image(True)
+        # noinspection PyArgumentList
         self.set_image(Gtk.Image.new_from_icon_name('split-{}'.format(direction), Gtk.IconSize.SMALL_TOOLBAR))
diff --git a/ocrd_browser/view/images.py b/ocrd_browser/view/images.py
index 862f4a3..158b186 100644
--- a/ocrd_browser/view/images.py
+++ b/ocrd_browser/view/images.py
@@ -1,4 +1,4 @@
-from gi.repository import Gtk, Gdk, GLib
+from gi.repository import Gtk, Gdk
 
 from typing import Any, List, Optional, Tuple
 
@@ -13,6 +13,7 @@
     ImageZoomSelector
 )
 from ..model import Page
+from ..util.gtk import WhenIdle
 
 
 class ViewImages(View):
@@ -58,11 +59,11 @@ def build(self) -> None:
     def config_changed(self, name: str, value: Any) -> None:
         super(ViewImages, self).config_changed(name, value)
         if name == 'page_qty':
-            GLib.idle_add(self.rebuild_pages, priority=GLib.PRIORITY_DEFAULT_IDLE)
-        if name == 'scale':
-            GLib.idle_add(self.rescale, priority=GLib.PRIORITY_DEFAULT_IDLE)
+            WhenIdle.call(self.rebuild_pages, priority=1)
         if name == 'file_group':
-            GLib.idle_add(self.reload, priority=GLib.PRIORITY_DEFAULT_IDLE)
+            WhenIdle.call(self.reload, priority=10)
+        if name == 'scale':
+            WhenIdle.call(self.rescale)
 
     def rebuild_pages(self) -> None:
         existing_pages = {child.get_name(): child for child in self.image_box.get_children()}
@@ -81,11 +82,11 @@ def rebuild_pages(self) -> None:
         for child in existing_pages.values():
             child.destroy()
 
-        GLib.idle_add(self.reload, priority=GLib.PRIORITY_DEFAULT_IDLE)
+        WhenIdle.call(self.reload, priority=10)
 
     def page_activated(self, _sender: Gtk.Widget, page_id: str) -> None:
         self.page_id = page_id
-        GLib.idle_add(self.reload, priority=GLib.PRIORITY_DEFAULT_IDLE)
+        WhenIdle.call(self.reload)
 
     @property
     def use_file_group(self) -> str:
@@ -97,7 +98,7 @@ def reload(self) -> None:
             self.pages = []
             for display_id in display_ids:
                 self.pages.append(self.document.page_for_id(display_id, self.use_file_group))
-        GLib.idle_add(self.redraw, priority=GLib.PRIORITY_DEFAULT_IDLE)
+        WhenIdle.call(self.redraw)
 
     def redraw(self) -> None:
         if self.pages:
@@ -128,10 +129,10 @@ def redraw(self) -> None:
                             else:
                                 image.set_tooltip_text(img_file.local_filename)
                     else:
-                        image.set_from_stock('missing-image', Gtk.IconSize.DIALOG)
+                        image.set_from_icon_name('missing-image', Gtk.IconSize.DIALOG)
                 for child in existing_images.values():
                     child.destroy()
-            GLib.idle_add(self.rescale, True, priority=GLib.PRIORITY_DEFAULT_IDLE)
+            WhenIdle.call(self.rescale, force=True)
 
     def rescale(self, force: bool = False) -> None:
         if self.pages:
diff --git a/ocrd_browser/view/page.py b/ocrd_browser/view/page.py
new file mode 100644
index 0000000..27c0b6c
--- /dev/null
+++ b/ocrd_browser/view/page.py
@@ -0,0 +1,478 @@
+from gi.repository import Gtk, Gdk, GObject, Pango, Gio, GLib
+
+from typing import Any, Optional, Tuple, Dict, List, NamedTuple, FrozenSet
+
+from pathlib import Path
+from PIL import Image
+from cairo import Context
+from xml.sax.saxutils import escape
+
+from ocrd_models.ocrd_page import AlternativeImageType
+from shapely.geometry import Polygon
+
+from ocrd_browser.util.image import pil_to_pixbuf, pil_scale
+from ocrd_utils.constants import MIMETYPE_PAGE
+from .base import (
+    View,
+    FileGroupSelector,
+    FileGroupFilter,
+    ImageZoomSelector,
+    Configurator
+)
+from ..model import LazyPage, Page, Document
+from ..model.page_xml_renderer import PageXmlRenderer, RegionMap, Feature, Region
+from ..util.gtk import WhenIdle, ActionRegistry
+
+
+class FeatureDescription:
+    def __init__(self, icon: str, label: str, xpath: str, tooltip: str = None):
+        self.icon = icon
+        self.label = label
+        self.xpath = xpath
+        self.tooltip = tooltip if tooltip else 'Show ' + label
+
+    def available(self, page: Page) -> bool:
+        return len(page.xpath(self.xpath)) > 0
+
+
+def clamp(x: float, lower: float, upper: float) -> float:
+    return lower if x < lower else upper if x > upper else x
+
+
+class Transformation:
+    """
+    Encapsulates forward and inverse affine transform consisting of scaling and translation only
+    """
+    def __init__(self, scale: float, tx: float, ty: float, cx: float, cy: float):
+        self.scale = scale
+        self.tx = tx
+        self.ty = ty
+        self.cx = cx
+        self.cy = cy
+
+    def transform_region(self, poly: Polygon) -> Tuple[float, float, float, float]:
+        (l, t), (r, b) = self.inverse(*poly.bounds[0:2]), self.inverse(*poly.bounds[2:4])
+        return l, t, r - l, b - t
+
+    def transform(self, x: float, y: float) -> Tuple[float, float]:
+        return clamp((x + self.tx) * self.scale, 0, self.cx), clamp((y + self.ty) * self.scale, 0, self.cy)
+
+    def inverse(self, x: float, y: float) -> Tuple[float, float]:
+        return clamp(x, 0, self.cx) / self.scale - self.tx, clamp(y, 0, self.cy) / self.scale - self.ty
+
+
+class ImageVersion(NamedTuple):
+    path: Path
+    size: Tuple[int, int]
+    features: FrozenSet[str] = frozenset()
+    conf: Optional[float] = None
+
+    def as_row(self) -> Tuple[str, str, str]:
+        return ','.join(sorted(self.features)), self.path.stem, '{0:d}✕{1:d}'.format(*self.size)
+
+    @classmethod
+    def from_page(cls, doc: Document, page: Page) -> 'ImageVersion':
+        return cls(doc.path(page.page.imageFilename), (page.page.imageWidth, page.page.imageHeight), frozenset(), None)
+
+    @classmethod
+    def from_alternative_image(cls, doc: Document, alt: AlternativeImageType) -> 'ImageVersion':
+        path = doc.path(alt.filename)
+        return cls(
+            path,
+            Image.open(path).size,
+            frozenset(str(alt.comments).split(',')),
+            float(alt.conf) if alt.conf is not None else None
+        )
+
+
+class ImageVersionSelector(Gtk.Box, Configurator):
+
+    def __init__(self) -> None:
+        super().__init__(visible=True, spacing=3)
+        self.value = None
+
+        self.versions = Gtk.ListStore(str, str, str)
+        self.version_box = Gtk.ComboBox(visible=True, model=self.versions)
+        self.version_box.set_id_column(0)
+
+        # label = Gtk.Label(label='Image:', visible=True)
+        # self.pack_start(label, False, True, 0)
+        self.pack_start(self.version_box, False, True, 0)
+
+        renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.START)
+        self.version_box.pack_start(renderer, False)
+        self.version_box.add_attribute(renderer, "text", 1)
+        self.set_tooltip_text('Image-Version')
+
+        # renderer = Gtk.CellRendererText()
+        # self.version_box.pack_start(renderer, False)
+        # self.version_box.add_attribute(renderer, "text", 2)
+
+        self._change_handler = self.version_box.connect('changed', self.combo_box_changed)
+
+    def set_value(self, value: str) -> None:
+        self.value = value
+        self.version_box.set_active_id(value)
+
+    @GObject.Signal(arg_types=[str])
+    def changed(self, image: str) -> None:
+        self.value = image
+
+    def set_page(self, page: Page) -> None:
+        versions = []
+        if page:
+            versions.append(ImageVersion.from_page(self.document, page))
+            alts: List[AlternativeImageType] = page.page.get_AlternativeImage()
+            for alt in alts:
+                versions.append(ImageVersion.from_alternative_image(self.document, alt))
+
+        with self.version_box.handler_block(self._change_handler):
+            self.versions.clear()
+            for version in versions:
+                self.versions.append(version.as_row())
+
+        if self.value is None:
+            self.version_box.set_active(0)
+        else:
+            if self.value != self.version_box.get_active_id():
+                self.version_box.set_active_id(self.value)
+
+    def combo_box_changed(self, combo: Gtk.ComboBox) -> None:
+        self.emit('changed', combo.get_active_id())
+
+
+class PageFeaturesSelector(Gtk.Box, Configurator):
+
+    FEATURES: Dict[Feature, FeatureDescription] = {
+        Feature.IMAGE: FeatureDescription('🖺', 'image', '/page:PcGts/page:Page/@imageFilename'),
+        Feature.BORDER: FeatureDescription('🗋', 'border', '/page:PcGts/page:Page/page:Border/page:Coords'),
+        Feature.PRINT_SPACE: FeatureDescription('🗌', 'printspace', '/page:PcGts/page:Page/page:PrintSpace/page:Coords'),
+        Feature.ORDER: FeatureDescription('↯', 'order', '/page:PcGts/page:Page/page:ReadingOrder/*'),
+        Feature.REGIONS: FeatureDescription('⬓', 'regions', '/page:PcGts/page:Page/*[not(local-name(.) = "Border" or local-name(.) = "PrintSpace")]/page:Coords'),
+        Feature.LINES: FeatureDescription('𝌆', 'lines', '/page:PcGts/page:Page/*//page:TextLine/page:Coords'),
+        Feature.WORDS: FeatureDescription('𝌶', 'words', '/page:PcGts/page:Page/*//page:TextLine/page:Word/page:Coords'),
+        Feature.GLYPHS: FeatureDescription('𝍖', 'glyphs', '/page:PcGts/page:Page/*//page:TextLine/page:Word/page:Glyph/page:Coords'),
+        # Feature.GRAPHEMES: FeatureDescription('#', 'graphemes', '/page:PcGts/page:Page/*//page:TextLine/page:Word/page:Glyph/page:Graphemes/*/page:Coords')
+        Feature.WARNINGS: FeatureDescription('‽', 'warnings', '/page:PcGts/page:Page//page:Coords', 'Mark regions with warnings in red')
+    }
+
+    def __init__(self) -> None:
+        super().__init__(visible=True, spacing=3)
+        self.value = None
+        self.items: Dict[Feature, Gtk.CheckMenuItem] = {}
+
+        menubutton = Gtk.MenuButton(label='Show:', visible=True)
+        self.menu_label: Gtk.AccelLabel = menubutton.get_child()
+
+        menu = Gtk.Menu(visible=True)
+        for feature, desc in self.FEATURES.items():
+            item = Gtk.CheckMenuItem(label=desc.label, name=feature.name, tooltip_text=desc.tooltip, visible=True)
+            item.get_child().set_markup('<tt>{0.icon}</tt>\t{0.label}'.format(desc))
+            item.connect("toggled", self.on_feature_toggled)
+            self.items[feature] = item
+            menu.append(item)
+
+        menubutton.set_direction(Gtk.ArrowType.DOWN)
+        menubutton.set_popup(menu)
+
+        self.pack_start(menubutton, False, False, 0)
+
+    def on_feature_toggled(self, item: Gtk.CheckMenuItem) -> None:
+        toggle_feature: Feature = Feature[item.get_name()]
+        if item.get_active():
+            self.value = Feature(self.value) | toggle_feature
+        else:
+            self.value = Feature(self.value) & ~toggle_feature
+        self.emit('changed', self.value)
+
+    def update_label_markup(self) -> None:
+        markup = 'Show: '
+        for feature, desc in self.FEATURES.items():
+            s = '<tt>' + desc.icon + '</tt>'
+            if not self.items[feature].get_sensitive():
+                s = '<span background="#eeeeee" foreground="#bbbbbb">{}</span>'.format(s)
+            elif self.items[feature].get_active():
+                s = '<span background="#ffffff" foreground="#000000"><b>{}</b></span>'.format(s)
+            else:
+                s = '<span background="#ffffff" foreground="#999999">{}</span>'.format(s)
+
+            markup += s
+        self.menu_label.set_markup(markup)
+
+    def set_value(self, value: int) -> None:
+        self.value = Feature(value)
+        for feature in self.FEATURES:
+            self.items[feature].set_active(feature & value)
+        self.emit('changed', self.value)
+
+    @GObject.Signal(arg_types=[object])
+    def changed(self, features: Feature) -> None:
+        self.update_label_markup()
+        self.value = features
+
+    def set_page(self, page: Page) -> None:
+        if page:
+            for feature, desc in self.FEATURES.items():
+                self.items[feature].set_sensitive(desc.available(page))
+            self.update_label_markup()
+
+
+class ViewPage(View):
+    """
+    PageViewer like View
+    """
+
+    label = 'Page'
+
+    def __init__(self, name: str, window: Gtk.Window):
+        super().__init__(name, window)
+        self.current: Optional[LazyPage] = None
+
+        # Configurators
+        self.file_group: Tuple[Optional[str], Optional[str]] = (None, MIMETYPE_PAGE)
+        self.scale: float = -2.0
+        self.image_version: str = ''
+        self.features: Feature = Feature.DEFAULT
+
+        # GTK
+        self.image: Optional[Gtk.Image] = None
+        self.highlight: Optional[Gtk.DrawingArea] = None
+        self.status_bar: Optional[Gtk.Box] = None
+
+        # Data
+        self.page_image: Optional[Image.Image] = None
+        self.region_map: Optional[RegionMap] = None
+        self.t: Optional[Transformation] = None
+        self.current_region: Optional[Region] = None
+        self.last_rescale: int = -100
+        self.viewport_size: Gdk.Rectangle
+
+    def build(self) -> None:
+        super(ViewPage, self).build()
+
+        self.add_configurator('file_group', FileGroupSelector(FileGroupFilter.PAGE))
+        self.add_configurator('scale', ImageZoomSelector(2.0, 0.05, -4.0, 2.0))
+        self.add_configurator('image_version', ImageVersionSelector())
+        self.add_configurator('features', PageFeaturesSelector())
+
+        actions = ActionRegistry()
+        actions.create(name='zoom_by', param_type=GLib.VariantType('i'), callback=self._on_zoom_by)
+        actions.create(name='zoom_to', param_type=GLib.VariantType('s'), callback=self._on_zoom_to)
+
+        app: Gtk.Application = self.window.get_application()
+        app.set_accels_for_action('view.zoom_by(1)', ['<Ctrl>plus'])
+        app.set_accels_for_action('view.zoom_by(-1)', ['<Ctrl>minus'])
+        app.set_accels_for_action('view.zoom_to::original', ['<Ctrl>0'])
+        app.set_accels_for_action('view.zoom_to::width', ['<Ctrl>numbersign'])
+        app.set_accels_for_action('view.zoom_to::page', ['<Ctrl><Alt>numbersign'])
+
+        self.image = Gtk.Image(visible=True, icon_name='gtk-missing-image', icon_size=Gtk.IconSize.DIALOG, valign=Gtk.Align.START)
+
+        self.highlight = Gtk.DrawingArea(visible=True, valign=Gtk.Align.FILL, halign=Gtk.Align.FILL, can_focus=True, has_focus=True, focus_on_click=True, is_focus=True)
+        self.highlight.add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
+        self.highlight.set_has_tooltip(True)
+        self.highlight.connect('query-tooltip', self._query_tooltip)
+        self.highlight.connect('scroll-event', self._on_scroll)
+        self.highlight.connect('button-press-event', self._on_button)
+        self.highlight.connect('motion-notify-event', self._on_mouse)
+        self.highlight.connect('draw', self.draw_highlight)
+        self.highlight.insert_action_group("view", actions.for_widget)
+
+        overlay = Gtk.Overlay(visible=True)
+        overlay.add(self.image)
+        overlay.add_overlay(self.highlight)
+
+        viewport = Gtk.Viewport(visible=True, hscroll_policy='natural', vscroll_policy='natural')
+        viewport.connect('size-allocate', self._on_viewport_size_allocate)
+        viewport.add(overlay)
+
+        self.scroller.add(viewport)
+        self.status_bar = Gtk.Box(visible=True, orientation=Gtk.Orientation.HORIZONTAL)
+        self.container.pack_end(self.status_bar, False, False, 0)
+        self.update_status_bar()
+
+    def config_changed(self, name: str, value: Any) -> None:
+        super(ViewPage, self).config_changed(name, value)
+        if name == 'file_group':
+            WhenIdle.call(self.reload, priority=10)
+        if name == 'features' or name == 'image_version':
+            WhenIdle.call(self.redraw, priority=50)
+        if name == 'scale':
+            WhenIdle.call(self.rescale)
+
+    @property
+    def use_file_group(self) -> str:
+        return self.file_group[0]
+
+    def redraw(self) -> None:
+        got_result = False
+        if self.current:
+            # TODO: store self.image_version as a frozenset
+            all_classes = frozenset({'binarized', 'grayscale_normalized', 'deskewed', 'despeckled', 'cropped', 'rotated-90', 'rotated-180', 'rotated-270'})
+            selected = frozenset(self.image_version.split(','))
+            page_image, page_coords, _ = self.current.get_image(feature_selector=','.join(selected), feature_filter=','.join(all_classes.difference(selected)))
+            if page_image:
+                renderer = PageXmlRenderer(page_image, page_coords, self.current.id, self.features)
+                renderer.render_all(self.current.pc_gts)
+                self.page_image, self.region_map = renderer.get_result()
+                self.current_region = self.region_map.refetch(self.current_region)
+                got_result = True
+        if not got_result:
+            self.page_image, self.region_map = None, None
+        self.update_transformation()
+        WhenIdle.call(self.rescale, force=True)
+
+    def rescale(self, force: bool = False) -> None:
+        if self.page_image:
+            scale_config: ImageZoomSelector = self.configurators['scale']
+            if force or abs(scale_config.value - self.last_rescale) > (scale_config.scale.get_adjustment().get_step_increment() - 0.0001):
+                self.last_rescale = scale_config.value
+                thumbnail = pil_scale(self.page_image, None, int(scale_config.get_exp() * self.page_image.height))
+                self.image.set_from_pixbuf(pil_to_pixbuf(thumbnail))
+        else:
+            self.image.set_from_icon_name('missing-image', Gtk.IconSize.DIALOG)
+        self.update_transformation()
+
+    def _on_mouse(self, _widget: Gtk.Overlay, e: Gdk.EventButton) -> None:
+        if self.t is None or self.region_map is None:
+            return
+
+        tx, ty = self.t.transform(e.x, e.y)
+        if self.region_map.find_region(tx, ty, ignore_regions=[]):
+            # noinspection PyArgumentList
+            cursor = Gdk.Cursor.new_from_name(self.container.get_display(), 'pointer')
+            self.container.get_window().set_cursor(cursor)
+            return
+
+        # noinspection PyArgumentList
+        cursor = Gdk.Cursor.new_from_name(self.container.get_display(), 'default')
+        self.container.get_window().set_cursor(cursor)
+
+    def _on_button(self, _widget: Gtk.Overlay, e: Gdk.EventButton) -> bool:
+        _widget.grab_focus()
+        old_region = self.current_region
+        if self.t is None or self.region_map is None:
+            self.current_region = None
+        else:
+            tx, ty = self.t.transform(e.x, e.y)
+            self.current_region = self.region_map.find_region(tx, ty)
+
+        if self.current_region is not old_region:
+            self.invalidate_region(self.current_region)
+            self.invalidate_region(old_region)
+            self.update_status_bar()
+
+        if e.button == Gdk.BUTTON_SECONDARY and e.type == Gdk.EventType.BUTTON_PRESS:
+            if self.current_region:
+                self._on_context_menu(e, self.current_region)
+        return False
+
+    def _on_context_menu(self, e: Gdk.EventButton, r: Region) -> bool:
+        p = Gtk.Menu(visible=True)
+        p.append(Gtk.MenuItem(visible=True, label=r.id))
+        p.append(Gtk.MenuItem(visible=True, label=r.text))
+        p.popup_at_pointer(e)
+        return False
+
+    def _on_scroll(self, _widget: Gtk.EventBox, event: Gdk.EventScroll) -> bool:
+        """
+        Handles zoom in / zoom out on Ctrl+mouse wheel
+        """
+        _widget.grab_focus()
+        accel_mask = Gtk.accelerator_get_default_mod_mask()
+        if event.state & accel_mask == Gdk.ModifierType.CONTROL_MASK:
+            did_scroll, delta_x, delta_y = event.get_scroll_deltas()
+            if did_scroll and abs(delta_y) > 0:
+                scale_config: ImageZoomSelector = self.configurators['scale']
+                scale_config.set_value(self.scale + delta_y * 0.1)
+                return True
+        return False
+
+    def _on_viewport_size_allocate(self, _sender: Gtk.Widget, rect: Gdk.Rectangle) -> None:
+        self.viewport_size = rect
+        self.update_transformation()
+
+    def _on_zoom_by(self, _action: Gio.SimpleAction, steps_v: Optional[GLib.Variant] = None) -> None:
+        scale_config: ImageZoomSelector = self.configurators['scale']
+        scale_config.zoom_by(steps_v.get_int32())
+        self.update_transformation()
+
+    def _on_zoom_to(self, _action: Gio.SimpleAction, to_v: Optional[GLib.Variant] = None) -> None:
+        scale_config: ImageZoomSelector = self.configurators['scale']
+        scale_config.zoom_to(
+            to_v.get_string(),
+            self.viewport_size.width / self.page_image.width,
+            self.viewport_size.height / self.page_image.height
+        )
+        self.update_transformation()
+
+    def _query_tooltip(self, _image: Gtk.Image, x: int, y: int, _keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
+        if self.t is None or self.region_map is None:
+            return False
+
+        tx, ty = self.t.transform(x, y)
+
+        region = self.region_map.find_region(tx, ty, ignore_regions=[])
+
+        content = '<tt>{:d}, {:d}</tt>'.format(int(tx), int(ty))
+        if region:
+            content += '\n<tt><big>{}</big></tt>\n\n{}'.format(str(region), escape(region.text))
+
+            if region.warnings:
+                content += '\n\n' + ('\n'.join(region.warnings))
+
+        tooltip.set_markup(content)
+
+        return True
+
+    def update_status_bar(self) -> None:
+        for w in self.status_bar.get_children():
+            self.status_bar.remove(w)
+
+        if self.current_region:
+            for i, r in enumerate(reversed(self.current_region.breadcrumbs())):
+                if i:
+                    self.status_bar.pack_start(Gtk.Separator(visible=True, orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
+
+                label = Gtk.Label(visible=True, label=str(r), ellipsize=Pango.EllipsizeMode.MIDDLE, max_width_chars=20, tooltip_text=str(r))
+                self.status_bar.pack_start(label, False, False, 5)
+        else:
+            self.status_bar.pack_start(Gtk.Label(visible=True, label=' '), False, False, 2)
+
+    def invalidate_region(self, r: Region) -> None:
+        if self.t is None or r is None:
+            return
+
+        x, y, w, h = self.t.transform_region(r.poly.buffer(5))
+        if w > 0 and h > 0:
+            self.highlight.queue_draw_area(x, y, w, h)
+
+    def draw_highlight(self, _area: Gtk.DrawingArea, context: Context) -> None:
+        if self.current_region and self.t:
+            poly: Polygon = self.current_region.poly
+            poly = poly.buffer(1, single_sided=True)
+            # TODO: 239, 134, 97, 0.7 taken from gtk.css, possible to get it from os????
+            context.set_source_rgba(239 / 255.0, 134 / 255.0, 97 / 255.0, 0.7)
+            context.set_line_width(clamp(self.configurators['scale'].get_exp() * 12, 0.5, 5))
+            context.new_path()
+            # Nice idea, but didnt work with scrolling: context.set_matrix(Matrix(1.0/self.t.scale, 0, 0, 1.0/self.t.scale, -self.t.tx, -self.t.ty))
+            for coord in poly.exterior.coords:
+                context.line_to(*self.t.inverse(*coord))
+            context.close_path()
+            context.stroke()
+
+    def update_transformation(self) -> None:
+        if self.page_image is None or self.image is None or self.image.get_pixbuf() is None:
+            return
+
+        pb = self.image.get_pixbuf()
+        size, _ = self.image.get_allocated_size()
+
+        self.t = Transformation(
+            self.page_image.width / pb.get_width(),
+            -(size.width - pb.get_width()) * 0.5,
+            -size.height * 0.0 + pb.get_height() * 0.0,
+            self.page_image.width,
+            self.page_image.height
+        )
+        self.highlight.queue_draw()
diff --git a/ocrd_browser/view/xml.py b/ocrd_browser/view/xml.py
index ea3a186..70ddf4b 100644
--- a/ocrd_browser/view/xml.py
+++ b/ocrd_browser/view/xml.py
@@ -29,7 +29,7 @@ def __init__(self, name: str, window: Gtk.Window):
 
     def build(self) -> None:
         super().build()
-        self.add_configurator('file_group', FileGroupSelector(FileGroupFilter.PAGE))
+        self.add_configurator('file_group', FileGroupSelector(FileGroupFilter.XML))
         button = Gtk.Button.new_with_label('PageViewer')
         button.connect('clicked', self.open_jpageviewer)
         button.set_visible(True)
diff --git a/requirements.txt b/requirements.txt
index 2df0632..abf6764 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ python-magic
 wheel
 setuptools
 lxml
+Shapely
diff --git a/setup.cfg b/setup.cfg
index 813f882..b6caca3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -39,3 +39,6 @@ ignore_missing_imports = True
 
 [mypy-ocrd_utils.*]
 ignore_missing_imports = True
+
+[mypy-shapely.*]
+ignore_missing_imports = True
diff --git a/setup.py b/setup.py
index 337ea3c..75109c2 100644
--- a/setup.py
+++ b/setup.py
@@ -52,7 +52,8 @@
             'html = ocrd_browser.view:ViewHtml',
             'text = ocrd_browser.view:ViewText',
             'images = ocrd_browser.view:ViewImages',
-            'diff = ocrd_browser.view:ViewDiff'
+            'diff = ocrd_browser.view:ViewDiff',
+            'page = ocrd_browser.view:ViewPage'
         ],
 
     },


Test patch:
diff --git a/tests/model/test_document.py b/tests/model/test_document.py
index bd9ddb3..4006df0 100644
--- a/tests/model/test_document.py
+++ b/tests/model/test_document.py
@@ -4,6 +4,7 @@
 from tests import TestCase, ASSETS_PATH
 from ocrd_browser.model import Document, Page
 from datetime import datetime
+from ocrd_models.ocrd_page import PcGtsType
 
 
 # TODO: Later: from tests.assets import Assets, copy_of_directory
@@ -156,12 +157,12 @@ def test_page_for_id_with_multiple_images_for_page_and_fileGrp(self):
         returns first image and warns
         """
         doc = Document.load(ASSETS_PATH / 'kant_aufklaerung_1784-complex/data/mets.xml')
-        with self.assertLogs('ocrd_browser.model.document', level='WARNING') as log_watch:
-            page = doc.page_for_id('PHYS_0017', 'OCR-D-IMG-CLIP')
+        # with self.assertLogs('ocrd_browser.model.document', level='WARNING') as log_watch:
+        page = doc.page_for_id('PHYS_0017', 'OCR-D-IMG-CLIP')
         self.assertIsInstance(page, Page)
-        self.assertEqual(1, len(log_watch.records))
-        self.assertEqual("No PAGE-XML but 2 images for page 'PHYS_0017' in fileGrp 'OCR-D-IMG-CLIP'",
-                         log_watch.records[0].msg)
+        self.assertIsInstance(page.pc_gts, PcGtsType)
+        # self.assertEqual(1, len(log_watch.records))
+        # self.assertEqual("No PAGE-XML but 2 images for page 'PHYS_0017' in fileGrp 'OCR-D-IMG-CLIP'", log_watch.records[0].msg)
 
     def test_modify_when_not_editable(self):
         doc = Document.load(self.path)
diff --git a/tests/model/test_page_xml_renderer.py b/tests/model/test_page_xml_renderer.py
new file mode 100644
index 0000000..b22e374
--- /dev/null
+++ b/tests/model/test_page_xml_renderer.py
@@ -0,0 +1,53 @@
+from tests import TestCase
+from ocrd_browser.model.page_xml_renderer import RegionFactory, Region
+from ocrd_models.ocrd_page import CoordsType, SeparatorRegionType
+
+
+class RegionFactoryTestCase(TestCase):
+
+    def setUp(self) -> None:
+        identity = {
+            'transform': [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
+            'angle': 0,
+            'features': ',normalized'
+        }
+        self.factory: RegionFactory = RegionFactory(identity, 'DUMMY_0001', None)
+
+    def test_create(self):
+        ds = SeparatorRegionType(id='r6', Coords=CoordsType(points="0,0 0,1 1,1 1,0"))
+        try:
+            with self.assertLogs('ocrd_browser.model.page_xml_renderer', level='WARNING') as log_watch:
+                region = self.factory.create(ds)
+                raise ValueError('Dummy instead of assertNoLogs')
+        except ValueError:
+            pass
+        self.assertEqual(len(log_watch.output), 0, '{:d} Warning(s) logged "{:s}'.format(len(log_watch.output), '\n'.join(log_watch.output)))
+        self.assertIsInstance(region, Region)
+        self.assertGreater(region.poly.area, 0)
+
+    def test_create_with_error(self):
+        ds = SeparatorRegionType(id='r6', Coords=CoordsType(points="1,1 1,1 1,1 1,1"))
+        with self.assertLogs('ocrd_browser.model.page_xml_renderer', level='ERROR') as log_watch:
+            region = self.factory.create(ds)
+        self.assertRegex(log_watch.output[0], r'ERROR:ocrd_browser\.model\.page_xml_renderer\.RegionFactory:Page "DUMMY_0001" @ SeparatorRegion#r6 Too few points.+')
+        self.assertIsNone(region)
+
+    def test_create_with_warning(self):
+        ds = SeparatorRegionType(id='r6', Coords=CoordsType(points="239,1303 508,1303 899,1302 1626,1307 2441,1307 2444,1319 2414,1322 1664,1319 619,1317 235,1317 237,1302 235,1302"))
+        with self.assertLogs('ocrd_browser.model.page_xml_renderer', level='WARNING') as log_watch:
+            region = self.factory.create(ds)
+        self.assertIsNotNone(region)
+        self.assertRegex(log_watch.output[0], r'WARNING:ocrd_browser\.model\.page_xml_renderer\.RegionFactory:Page "DUMMY_0001" @ SeparatorRegion#r6 Self-intersection.+')
+        self.assertRegex(region.warnings[0], r'Self-intersection.+')
+
+    def test_create_with_warning_negative(self):
+        ds = SeparatorRegionType(id='r6', Coords=CoordsType(points="0,0 0,-1 -1,-1 -1,0"))
+        with self.assertLogs('ocrd_browser.model.page_xml_renderer', level='WARNING'):
+            region = self.factory.create(ds)
+        self.assertRegex(region.warnings[0], r'is negative')
+
+    def test_create_with_warning_too_few_points(self):
+        ds = SeparatorRegionType(id='r6', Coords=CoordsType(points="0,0 0,1 1,1"))
+        with self.assertLogs('ocrd_browser.model.page_xml_renderer', level='WARNING'):
+            region = self.factory.create(ds)
+        self.assertRegex(region.warnings[0], r'has too few points')
diff --git a/tests/util/test_file_groups.py b/tests/util/test_file_groups.py
new file mode 100644
index 0000000..7459549
--- /dev/null
+++ b/tests/util/test_file_groups.py
@@ -0,0 +1,41 @@
+import unittest
+
+from tests import TestCase
+from ocrd_browser.util.file_groups import best_file_group, weight_match
+
+
+class FileGroupsTestCase(TestCase):
+
+    def setUp(self) -> None:
+        self.test_groups = [
+            ('OCR-D-SEG-BLOCK-tesseract', 'application/vnd.prima.page+xml'),
+            ('OCR-D-GT-PAGE', 'application/vnd.prima.page+xml'),
+            ('OCR-D-IMG', 'image/tiff'),
+            ('OCR-D-IMG-BIN', 'image/tiff'),
+        ]
+
+    def test_weight_match(self):
+        self.assertEqual(0, weight_match('ABC', []))
+        self.assertEqual(1, weight_match('ABC', ['ABC']))
+        self.assertEqual(1, weight_match('ABC', ['ABC', 'DEF']))
+        self.assertEqual(0.5, weight_match('DEF', ['ABC', 'DEF']))
+        self.assertEqual(0, weight_match('DEF', ['ABC']))
+
+    def test_weight_match_re(self):
+        self.assertEqual(1, weight_match('ABC', [r'AB[CD]']))
+        # 1.0 for match ABC + 0.5 match \w{3}
+        self.assertEqual(1.5, weight_match('ABC', ['ABC', r'\w{3}']))
+
+    def test_best_file_group_prefers_shortest_file_group_if_no_other_preferences(self):
+        expected = ('OCR-D-IMG', 'image/tiff')
+        actual = best_file_group(self.test_groups)
+        self.assertEqual(expected, actual)
+
+    def test_best_file_group_prefers_by_mimetype(self):
+        expected = ('OCR-D-GT-PAGE', 'application/vnd.prima.page+xml')
+        actual = best_file_group(self.test_groups, preferred_mimetypes=[r'application/.*'])
+        self.assertEqual(expected, actual)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/util/test_image.py b/tests/util/test_image.py
index cda0ab0..3cd2ed5 100644
--- a/tests/util/test_image.py
+++ b/tests/util/test_image.py
@@ -31,7 +31,7 @@ def _image_modes():
 class ImageUtilTestCase(TestCase):
 
     def test_pil_to_pixbuf_is_faster_via_opencv(self):
-        self.skipTest('Slow test')
+        # self.skipTest('Slow test')
         files = [
             ASSETS_PATH / 'kant_aufklaerung_1784-binarized/data/OCR-D-IMG/OCR-D-IMG_0017.tif',
             ASSETS_PATH / 'kant_aufklaerung_1784-binarized/data/OCR-D-IMG-1BIT/OCR-D-IMG-1BIT_0017.png',
diff --git a/tests/view/test_page.py b/tests/view/test_page.py
new file mode 100644
index 0000000..ccbafe6
--- /dev/null
+++ b/tests/view/test_page.py
@@ -0,0 +1,18 @@
+import unittest
+from unittest.mock import MagicMock
+from ocrd_browser.view import ViewPage
+from ocrd_browser.ui import MainWindow
+from tests import TestCase
+
+
+class ViewPageTestCase(TestCase):
+
+    def setUp(self):
+        self.vx = ViewPage('unique', MagicMock(spec=MainWindow))
+
+    def test_can_construct(self):
+        self.assertIsNotNone(self.vx)
+
+
+if __name__ == '__main__':
+    unittest.main()