Repository: Princeton-CDH/piffle (language: python)

Issue:
move iiif presentation model from djiffy to piffle
this is code from djiffy that makes more sense in piffle for easier reuse (nothing django-specific about it); restructured piffle slightly and then copied/adapted the existing code & tests for reading iiif manifests


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..e6bb784
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,53 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ main, develop ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ main, develop ]
+  schedule:
+    - cron: '34 21 * * 1'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'python' ]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
new file mode 100644
index 0000000..4e1ef42
--- /dev/null
+++ b/.github/workflows/python-publish.yml
@@ -0,0 +1,31 @@
+# This workflows will upload a Python Package using Twine when a release is created
+# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
+
+name: Upload Python Package
+
+on:
+  release:
+    types: [created]
+
+jobs:
+  deploy:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install setuptools wheel twine
+    - name: Build and publish
+      env:
+        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+      run: |
+        python setup.py sdist bdist_wheel
+        twine upload dist/*
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 77f832d..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-language: python
-
-python:
-  - "2.7"
-  - "3.4"
-  - "3.5"
-  - "3.6"
-
-install:
-  - pip install --upgrade setuptools
-  - pip install -e .
-  - pip install -e ".[test]"
-  - pip install codecov
-
-script:
-  - py.test --cov=piffle
-
-after_success:
-  - codecov
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20c5277..a60acf2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Change & Version Information
 
+## 0.4
+
+* Dropped support for Python versions 2.7, 3.4, 3.5
+* Now tested against python 3.7 and 3.8
+* Moved continues integration from Travis-CI to GitHub Actions
+* Renamed `piffle.iiif` to `piffle.image`, but for backwards compatibility `piffle.iiif` will still work
+* Now includes `piffle.presentation` for simple read access to IIIF Presentation content
+
 ## 0.3.2
 
 * Dropped support for Python 3.3
diff --git a/README.md b/README.md
index 4b0cdd6..f2d6d7b 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,14 @@
 Python library for generating and parsing [IIIF Image API](http://iiif.io/api/image/2.1/) URLs in an
 object-oriented, pythonic fashion.
 
-[![Build Status](https://travis-ci.org/Princeton-CDH/piffle.svg?branch=master)](https://travis-ci.org/Princeton-CDH/piffle)
-[![Coverage Status](https://coveralls.io/repos/github/Princeton-CDH/piffle/badge.svg?branch=master)](https://coveralls.io/github/Princeton-CDH/piffle?branch=master)
+[![Unit Test Status](https://github.com/Princeton-CDH/piffle/workflows/unit_tests/badge.svg)](https://github.com/Princeton-CDH/piffle/actions?query=workflow%3Aunit_tests)
+[![codecov](https://codecov.io/gh/Princeton-CDH/piffle/branch/main/graph/badge.svg)](https://codecov.io/gh/Princeton-CDH/piffle)
 [![Maintainability](https://api.codeclimate.com/v1/badges/d37850d90592f9d628df/maintainability)](https://codeclimate.com/github/Princeton-CDH/piffle/maintainability)
 
-Piffle is tested on Python 2.7 and 3.4-3.6.
 
-Piffle was originally developed by Emory University as a part of
-[Readux](https://github.com/ecds/readux>) and forked as a separate project
-under [emory-lits-labs](https://github.com/emory-lits-labs/).
+Piffle is tested on Python 3.6-3.8.
+
+Piffle was originally developed by Rebecca Sutton Koeser at Emory University as a part of [Readux](https://github.com/ecds/readux>) and forked as a separate project under [emory-lits-labs](https://github.com/emory-lits-labs/). It was later transferred to Rebecca Sutton Koeser at the Center for Digital Humanities at Princeton.
 
 ## Installation and example use:
 
@@ -20,7 +19,7 @@ under [emory-lits-labs](https://github.com/emory-lits-labs/).
 Example use for generating an IIIF image url:
 
 ```
->>> from piffle.iiif import IIIFImageClient
+>>> from piffle.image import IIIFImageClient
 >>> myimg = IIIFImageClient('http://image.server/path/', 'myimgid')
 >>> print myimg
 http://image.server/path/myimgid/full/full/0/default.jpg
@@ -33,7 +32,7 @@ http://image.server/path/myimgid/full/120,/0/default.png
 Example use for parsing an IIIF image url:
 
 ```
->>> from piffle.iiif import IIIFImageClient
+>>> from piffle.image import IIIFImageClient
 >>> myimg = IIIFImageClient.init_from_url('http://www.example.org/image-service/abcd1234/full/full/0/default.jpg')
 >>> print myimg
 http://www.example.org/image-service/abcd1234/full/full/0/default.jpg
@@ -47,6 +46,30 @@ False
 0.0
 ```
 
+Example use for reading a IIIF manifest:
+
+```
+>>> from piffle.image import IIIFImageClient
+>>> from piffle.presentation import IIIFPresentation
+>>>  manifest = IIIFPresentation.from_url('https://iiif.bodleian.ox.ac.uk/iiif/manifest/60834383-7146-41ab-bfe1-48ee97bc04be.json')
+>>> manifest.label
+'Bodleian Library MS. Bodl. 264'
+>>> manifest.id
+'https://iiif.bodleian.ox.ac.uk/iiif/manifest/60834383-7146-41ab-bfe1-48ee97bc04be.json'
+>>> manifest.type
+'sc:Manifest'
+>>> for canvas in manifest.sequences[0].canvases[:5]:
+...     image_id = canvas.images[0].resource.id
+...     iiif_img = IIIFImageClient(*image_id.rsplit('/', 1))
+...     print(str(iiif_img.size(height=250)))
+...
+https://iiif.bodleian.ox.ac.uk/iiif/image/90701d49-5e0c-4fb5-9c7d-45af96565468/full/,250/0/default.jpg
+https://iiif.bodleian.ox.ac.uk/iiif/image/e878cc78-acd3-43ca-ba6e-90a392f15891/full/,250/0/default.jpg
+https://iiif.bodleian.ox.ac.uk/iiif/image/0f1ed064-a972-4215-b884-d8d658acefc5/full/,250/0/default.jpg
+https://iiif.bodleian.ox.ac.uk/iiif/image/6fe52b9a-5bb7-4b5b-bbcd-ad0489fcad2a/full/,250/0/default.jpg
+https://iiif.bodleian.ox.ac.uk/iiif/image/483ff8ec-347d-4070-8442-dbc15bc7b4de/full/,250/0/default.jpg
+```
+
 ## Development and Testing
 
 This project uses [git-flow](https://github.com/nvie/gitflow) branching conventions.
diff --git a/piffle/__init__.py b/piffle/__init__.py
index 0aabe71..606caa3 100644
--- a/piffle/__init__.py
+++ b/piffle/__init__.py
@@ -1,4 +1,4 @@
-__version_info__ = (0, 3, 2, None)
+__version_info__ = (0, 4, 0, None)
 
 # Dot-connect all but the last. Last is dash-connected if not None.
 __version__ = '.'.join([str(i) for i in __version_info__[:-1]])
diff --git a/piffle/iiif.py b/piffle/iiif.py
index bdda724..14bac32 100644
--- a/piffle/iiif.py
+++ b/piffle/iiif.py
@@ -1,729 +1,5 @@
-# iiifclient
-# -*- coding: utf-8 -*-
+# for backwards compatibility, allow import from piffle.iiif
+from piffle.image import *
 
-from collections import OrderedDict
-from future.utils import python_2_unicode_compatible
-import six
-from six.moves.urllib.parse import urlparse
-
-from cached_property import cached_property
-import requests
-
-
-class IIIFImageClientException(Exception):
-    '''IIIFImageClient custom exception class'''
-    pass
-
-
-class ParseError(IIIFImageClientException):
-    '''Exception raised when an IIIF image could not be parsed'''
-    pass
-
-
-# NOTE: possible image component base class?
-# commonalities so far: setting defaults on init / parse
-# handling exact matches like full/square? (but maybe only region/size),
-# validating options (and could add option type checking)
-
-
-@python_2_unicode_compatible
-class ImageRegion(object):
-    '''IIIF Image region. Intended to be used with :class:`IIIFImageClient`.
-    Can be initialized with related image object and region options.
-
-    When associated with an image, region is callable and will return
-    an updated image object with the modified region options.
-
-    :param img: :class:`IIFImageClient`
-    :param full: full region, defaults to true
-    :param square: square region, defaults to false
-    :param x: x coordinate
-    :param y: y coordinate
-    :param width: region width
-    :param height: region height
-    :param percent: region is a percentage
-    '''
-
-    # region options
-    options = OrderedDict([
-        ('full', False),
-        ('square', False),
-        ('x', None),
-        ('y', None),
-        ('width', None),
-        ('height', None),
-        ('percent', False)
-    ])
-
-    region_defaults = {
-        'full': True,
-        'square': False,
-        'x': None,
-        'y': None,
-        'width': None,
-        'height': None,
-        'percent': False
-    }
-
-    coords = ['x', 'y', 'width', 'height']
-
-    def __init__(self, img=None, **options):
-        self.img = img
-        self.options = self.region_defaults.copy()
-        if options:
-            self.set_options(**options)
-
-    def __call__(self, **options):
-        if self.img is not None:
-            img = self.img.get_copy()
-            img.region.set_options(**options)
-            return img
-
-    def set_options(self, **options):
-        '''Update region options.  Same parameters as initialiation.'''
-        allowed_options = list(self.options.keys())
-        # error if an unrecoganized option is specified
-        for key in options:
-            if key not in allowed_options:
-                raise IIIFImageClientException('Unknown option: %s' % key)
-
-        # error if some but not all coordinates are specified
-        # or if percentage is specified but not all coordinates are present
-        if (any([coord in options for coord in self.coords]) or
-           'percent' in options) and not \
-           all([coord in options for coord in self.coords]):
-            # partial region specified
-            raise IIIFImageClientException('Incomplete region specified')
-
-        # TODO: do we need to type checking? bool/int/float?
-
-        self.options.update(**options)
-        # if any non-full value is specified, set full to false
-        # NOTE: if e.g. square is specified but false, this is wrong
-        allowed_options.remove('full')
-        if any([(key in allowed_options and options[key])
-               for key in options.keys()]):
-            self.options['full'] = False
-
-    def as_dict(self):
-        '''Return region options as a dictionary'''
-        return self.options
-
-    def __str__(self):
-        '''Render region information in IIIF region format'''
-        if self.options['full']:
-            return 'full'
-        if self.options['square']:
-            return 'square'
-
-        coords = '%(x)g,%(y)g,%(width)g,%(height)g' % self.options
-        if self.options['percent']:
-            return 'pct:%s' % coords
-
-        return coords
-
-    def parse(self, region):
-        '''Parse an IIIF Image region string and update the current region'''
-
-        # reset to defaults before parsing
-        self.options = self.region_defaults.copy()
-
-        # full?
-        if region == 'full':
-            self.options['full'] = True
-            # return immediately
-            return
-        else:
-            self.options['full'] = False
-
-        if region == 'square':
-            self.options['square'] = True
-            # return immediately
-            return self
-
-        # percent?
-        if "pct" in region:
-            self.options['percent'] = True
-            region = region.split("pct:")[1]
-
-        # split to dictionary
-        # if percentage type, cast to float
-        try:
-            if self.options['percent']:
-                coords = [float(region_c) for region_c in region.split(",")]
-            # else, force int
-            else:
-                coords = [int(region_c) for region_c in region.split(",")]
-        except ValueError:
-            # failure converting to integer or float
-            raise ParseError('Invalid region coordinates: %s' % region)
-
-        if len(coords) != 4:
-            raise ParseError('Invalid region coordinates: %s' % region)
-
-        x, y, width, height = coords
-        self.options.update({'x': x, 'y': y, 'width': width, 'height': height})
-
-    def canonicalize(self):
-        '''Canonicalize the current region options so that
-        serialization results in canonical format.'''
-        # From the spec:
-        #   “full” if the whole image is requested, (including a “square”
-        #    region of a square image), otherwise the x,y,w,h syntax.
-
-        if self.options['full']:
-            # nothing to do
-            return
-
-        # if already in x,y,w,h format - nothing to do
-        if not any([self.options['full'], self.options['square'],
-                    self.options['percent']]):
-            return
-
-        # possbly an error here for every other case if self.img is not set,
-        # since it's probably not possible to canonicalize without knowing
-        # image size  (any exceptions?)
-        if self.img is None:
-            raise IIIFImageClientException('Cannot canonicalize without image')
-
-        if self.options['square']:
-            # if image is square, then return full
-            if self.img is not None and \
-             self.img.image_width == self.img.image_height:
-                self.options['full'] = True
-                self.options['square'] = False
-                return
-
-            # otherwise convert to x,y,w,h
-            # from the spec:
-            #  The region is defined as an area where the width and height
-            #  are both equal to the length of the shorter dimension of the
-            #  complete image. The region may be positioned anywhere in the
-            #  longer dimension of the image content at the server’s
-            #  discretion, and centered is often a reasonable default.
-
-            # determine size of the short edge
-            short_edge = min(self.img.image_width, self.img.image_height)
-            width = height = short_edge
-            # calculate starting long edge point to center the square
-            if self.img.image_height == short_edge:
-                y = 0
-                x = (self.img.image_width - short_edge) / 2
-            else:
-                x = 0
-                y = (self.img.image_height - short_edge) / 2
-
-            self.options.update({'x': x, 'y': y, 'width': width,
-                                 'height': height, 'square': False})
-
-        if self.options['percent']:
-            # convert percentages to x,y,w,h
-            # From the spec:
-            #  The region to be returned is specified as a sequence of
-            #  percentages of the full image’s dimensions, as reported in
-            #  the image information document. Thus, x represents the number
-            #  of pixels from the 0 position on the horizontal axis, calculated
-            #  as a percentage of the reported width. w represents the width
-            #  of the region, also calculated as a percentage of the reported
-            #  width. The same applies to y and h respectively. These may be
-            #  floating point numbers.
-
-            # convert percentages to dimensions based on image size
-            self.options.update({
-                'percent': False,
-                'x': int((self.options['x']/100) * self.img.image_width),
-                'y': int((self.options['y']/100) * self.img.image_height),
-                'width': int((self.options['width']/100) * self.img.image_width),
-                'height': int((self.options['height']/100) * self.img.image_height)
-            })
-            return
-
-
-@python_2_unicode_compatible
-class ImageSize(object):
-    '''IIIF Image Size.  Intended to be used with :class:`IIIFImageClient`.
-    Can be initialized with related image object and size options.
-
-    When associated with an image, size is callable and will return
-    an updated image object with the modified size.
-
-    :param img: :class:`IIFImageClient`
-    :param width: optional width
-    :param height: optional height
-    :param percent: optional percent
-    :param exact: size should be exact (boolean, optional)
-    '''
-
-    # size options
-    options = OrderedDict([
-        ('full', False),
-        ('max', False),
-        ('width', None),
-        ('height', None),
-        ('percent', None),
-        ('exact', False)
-    ])
-
-    # NOTE: full is being deprecated and replaced with max;
-    # full is deprecated in 2.1 and will be removed for 3.0
-    # Eventually piffle will need to address that, maybe with some kind of
-    # support for selecting a particular version of the IIIF image spec.
-    # For now, default size is still full, and max and full are treated as
-    # separate modes.  A parsed url with max will return max, and a parsed
-    # url with full will return full, but that will probably change
-    # once the deprecated full is handled properly.
-
-    size_defaults = {
-        'full': True,
-        'max': False,
-        'width': None,
-        'height': None,
-        'percent': None,
-        'exact': False
-    }
-
-    def __init__(self, img=None, **options):
-        self.img = img
-        self.options = self.size_defaults.copy()
-        if options:
-            self.set_options(**options)
-
-    def __call__(self, **options):
-        if self.img is not None:
-            img = self.img.get_copy()
-            img.size.set_options(**options)
-            return img
-
-    def set_options(self, **options):
-        '''Update size options.  Same parameters as initialiation.'''
-        allowed_options = list(self.options.keys())
-        # error if an unrecoganized option is specified
-        for key in options:
-            if key not in allowed_options:
-                raise IIIFImageClientException('Unknown option: %s' % key)
-
-        # TODO: do we need to type checking? bool/int/float?
-
-        self.options.update(**options)
-        # if any non-full value is specified, set full to false
-        # NOTE: if e.g. square is specified but false, this is wrong
-        allowed_options.remove('full')
-        if any([key in allowed_options and options[key]
-                for key in options.keys()]):
-            self.options['full'] = False
-
-    def as_dict(self):
-        '''Return size options as a dictionary'''
-        return self.options
-
-    def __str__(self):
-        if self.options['full']:
-            return 'full'
-        if self.options['max']:
-            return 'max'
-        if self.options['percent']:
-            return 'pct:%g' % self.options['percent']
-
-        size = '%s,%s' % (self.options['width'] or '',
-                          self.options['height'] or '')
-        if self.options['exact']:
-            return '!%s' % size
-        return size
-
-    def parse(self, size):
-        # reset to defaults before parsing
-        self.options = self.size_defaults.copy()
-
-        # full?
-        if size == 'full':
-            self.options['full'] = True
-            return
-        # for any other case, full should be false
-        else:
-            self.options['full'] = False
-
-        # max?
-        if size == 'max':
-            self.options['max'] = True
-            return
-
-        # percent?
-        if "pct" in size:
-            try:
-                self.options['percent'] = float(size.split(":")[1])
-                return
-            except ValueError:
-                raise ParseError('Error parsing size: %s' % size)
-
-        # exact?
-        if size.startswith('!'):
-            self.options['exact'] = True
-            size = size.lstrip('!')
-
-        # split width and height
-        width, height = size.split(",")
-        try:
-            if width != '':
-                self.options['width'] = int(width)
-            if height != '':
-                self.options['height'] = int(height)
-        except ValueError:
-            raise ParseError('Error parsing size: %s' % size)
-
-    def canonicalize(self):
-        '''Canonicalize the current size options so that
-        serialization results in canonical format.'''
-        # From the spec:
-        #   “full” if the default size is requested,
-        #   the w, syntax for images that should be scaled maintaining the
-        #   aspect ratio, and the w,h syntax for explicit sizes that change
-        #   the aspect ratio.
-        #   Note: The size keyword “full” will be replaced with “max” in
-        #   version 3.0
-
-        if self.options['full']:
-            # nothing to do
-            return
-
-        # possbly an error here for every other case if self.img is not set,
-        # since it's probably not possible to canonicalize without knowing
-        # image size  (any exceptions?)
-        if self.img is None:
-            raise IIIFImageClientException('Cannot canonicalize without image')
-
-        if self.options['percent']:
-            # convert percentage to w,h
-            scale = self.options['percent'] / 100
-            self.options.update({
-                'height': int(self.img.image_height * scale),
-                'width': int(self.img.image_width * scale),
-                'percent': None,
-            })
-            return
-
-        if self.options['exact']:
-            # from the spec:
-            #   The image content is scaled for the best fit such that the
-            #   resulting width and height are less than or equal to the
-            #   requested width and height. The exact scaling may be determined
-            #   by the service provider, based on characteristics including
-            #   image quality and system performance. The dimensions of the
-            #   returned image content are calculated to maintain the aspect
-            #   ratio of the extracted region.
-
-            # determine which edge results in a smaller scale
-            wscale = float(self.options['width']) / float(self.img.image_width)
-            hscale = float(self.options['height']) / float(self.img.image_height)
-            scale = min(wscale, hscale)
-            # use that scale on original image size, to preserve
-            # original aspect ratio
-            self.options.update({
-                'exact': False,
-                'height': int(scale * self.img.image_height),
-                'width': int(scale * self.img.image_width)
-            })
-            return
-
-        # if height only is specified (,h), convert to width only (w,)
-        if self.options['height'] and self.options['width'] is None:
-            # determine the scale in use, and convert from
-            # height only to width only
-            scale = float(self.options['height']) / float(self.img.image_height)
-            self.options.update({
-                'height': None,
-                'width': int(scale * self.img.image_width)
-            })
-
-
-@python_2_unicode_compatible
-class ImageRotation(object):
-    '''IIIF Image rotation Intended to be used with :class:`IIIFImageClient`.
-    Can be initialized with related image object and rotation options.
-
-    When associated with an image, rotation is callable and will return
-    an updated image object with the modified rotatoin options.
-
-    :param img: :class:`IIFImageClient`
-    :param degrees: degrees rotation, optional
-    :param mirrored: image should be mirrored (boolean, optional, default
-       is False)
-    '''
-
-    # rotation options
-    options = OrderedDict([
-        ('degrees', None),
-        ('mirrored', False),
-    ])
-
-    rotation_defaults = {
-        'degrees': 0,
-        'mirrored': False
-    }
-
-    def __init__(self, img=None, **options):
-        self.img = img
-        self.options = self.rotation_defaults.copy()
-        if options:
-            self.set_options(**options)
-
-    def __call__(self, **options):
-        if self.img is not None:
-            img = self.img.get_copy()
-            img.rotation.set_options(**options)
-            return img
-
-    def set_options(self, **options):
-        '''Update size options.  Same parameters as initialiation.'''
-        allowed_options = self.options.keys()
-        # error if an unrecoganized option is specified
-        for key in options:
-            if key not in allowed_options:
-                raise IIIFImageClientException('Unknown option: %s' % key)
-
-        # TODO: do we need to type checking? bool/int/float?
-
-        self.options.update(**options)
-
-    def as_dict(self):
-        '''Return rotation options as a dictionary'''
-        return self.options
-
-    def __str__(self):
-        return '%s%g' % ('!' if self.options['mirrored'] else '',
-                         self.options['degrees'])
-
-    def parse(self, rotation):
-        # reset to defaults before parsing
-        self.options = self.rotation_defaults.copy()
-
-        if str(rotation).startswith('!'):
-            self.options['mirrored'] = True
-            rotation = rotation.lstrip('!')
-
-        # rotation allows float
-        self.options['degrees'] = float(rotation)
-
-    def canonicalize(self):
-        '''Canonicalize the current region options so that
-        serialization results in canonical format.'''
-        # NOTE: explicitly including a canonicalize as a method to make it
-        # clear that this field supports canonicalization, but no work
-        # is needed since the existing render does the right things:
-        # - trim any trailing zeros in a decimal value
-        # - leading zero if less than 1
-        # - ! if mirrored, followed by integer if possible
-        return
-
-
-@python_2_unicode_compatible
-class IIIFImageClient(object):
-    '''Simple IIIF Image API client for generating IIIF image urls
-    in an object-oriented, pythonic fashion.  Can be extended,
-    when custom logic is needed to set the image id.  Provides
-    a fluid interface, so that IIIF methods can be chained, e.g.::
-
-        iiif_img.size(width=300).rotation(90).format('png')
-
-   Note that this returns a new image instance with the specified
-   options, and the original image will remain unchanged.
-
-    .. Note::
-
-        Method to set quality not yet available.
-    '''
-
-    api_endpoint = None
-    image_id = None
-    default_format = 'jpg'
-
-    # iiif defaults for each sections
-    image_defaults = {
-        'quality': 'default',  # color, gray, bitonal, default
-        'fmt': default_format
-    }
-    allowed_formats = ['jpg', 'tif', 'png', 'gif', 'jp2', 'pdf', 'webp']
-
-    def __init__(self, api_endpoint=None, image_id=None, region=None,
-                 size=None, rotation=None, quality=None, fmt=None):
-        self.image_options = self.image_defaults.copy()
-        # NOTE: using underscore to differenteate objects from methods
-        # but it could be reasonable to make objects public
-        self.region = ImageRegion(self)
-        self.size = ImageSize(self)
-        self.rotation = ImageRotation(self)
-
-        if api_endpoint is not None:
-            # remove any trailing slash to avoid duplicate slashes
-            self.api_endpoint = api_endpoint.rstrip('/')
-
-        # FIXME: image_id is not required on init to allow subclassing
-        # and customizing via get_image_id, but should probably cause
-        # an error if you attempt to serialize the url and it is not set
-        # (same for a few other options, probably, too...)
-        if image_id is not None:
-            self.image_id = image_id
-
-        # for now, if region option is specified parse as string
-        if region is not None:
-            self.region.parse(region)
-        if size is not None:
-            self.size.parse(size)
-        if rotation is not None:
-            self.rotation.parse(rotation)
-
-        if quality is not None:
-            self.image_options['quality'] = quality
-        if fmt is not None:
-            self.image_options['fmt'] = fmt
-
-    def get_image_id(self):
-        'Image id to be used in contructing urls'
-        return self.image_id
-
-    def __str__(self):
-        info = self.image_options.copy()
-        info.update({
-            'endpoint': self.api_endpoint,
-            'id': self.get_image_id(),
-            'region': six.text_type(self.region),
-            'size': six.text_type(self.size),
-            'rot': six.text_type(self.rotation)
-        })
-        return '%(endpoint)s/%(id)s/%(region)s/%(size)s/%(rot)s/%(quality)s.%(fmt)s' % info
-
-    def __repr__(self):
-        return '<IIIFImageClient %s>' % self.get_image_id()
-        # include non-defaults?
-
-    def info(self):
-        'JSON info url'
-        return '%(endpoint)s/%(id)s/info.json' % {
-            'endpoint': self.api_endpoint,
-            'id': self.get_image_id(),
-        }
-
-    @cached_property
-    def image_info(self):
-        'Retrieve image information provided as JSON at info url'
-        resp = requests.get(self.info())
-        if resp.status_code == requests.codes.ok:
-            return resp.json()
-        else:
-            resp.raise_for_status()
-
-    @property
-    def image_width(self):
-        'Image width as reported in :attr:`image_info`'
-        return self.image_info['width']
-
-    @property
-    def image_height(self):
-        'Image height as reported in :attr:`image_info`'
-        return self.image_info['height']
-
-    def get_copy(self):
-        'Get a clone of the current settings for modification.'
-        clone = self.__class__(self.api_endpoint, self.image_id,
-                               **self.image_options)
-        # copy region, size, and rotation - no longer included in
-        # image_options dict
-        clone.region.set_options(**self.region.as_dict())
-        clone.size.set_options(**self.size.as_dict())
-        clone.rotation.set_options(**self.rotation.as_dict())
-        return clone
-
-    # method to set quality not yet implemented
-
-    def format(self, image_format):
-        'Set output image format'
-        if image_format not in self.allowed_formats:
-            raise IIIFImageClientException('Image format %s unknown' % image_format)
-        img = self.get_copy()
-        img.image_options['fmt'] = image_format
-        return img
-
-    def canonicalize(self):
-        '''Canonicalize the URI'''
-        img = self.get_copy()
-        img.region.canonicalize()
-        img.size.canonicalize()
-        img.rotation.canonicalize()
-        return img
-
-    @classmethod
-    def init_from_url(cls, url):
-        '''Init ImageClient using Image API parameters from URI.  Detect
-        image vs. info request. Can count reliably from the end of the URI
-        backwards, but cannot assume how many slashes make up the api_endpoint.
-        Returns new instance of IIIFImageClient.
-        Per http://iiif.io/api/image/2.0/#image-request-uri-syntax, using
-        slashes to parse URI'''
-
-        # first parse as a url
-        parsed_url = urlparse(url)
-        # then split the path on slashes
-        # and remove any empty strings
-        path_components = [path for path in parsed_url.path.split('/') if path]
-        if not path_components:
-            raise ParseError('Invalid IIIF image url: %s'
-                                % url)
-        # pop off last portion of the url to determine if this is an info url
-        path_basename = path_components.pop()
-        opts = {}
-
-        # info request
-        if path_basename == 'info.json':
-            # NOTE: this is unlikely to happen; more likely, if information is
-            # missing, we will misinterpret the api endpoint or the image id
-            if len(path_components) < 1:
-                raise ParseError('Invalid IIIF image information url: %s'
-                                    % url)
-            image_id = path_components.pop()
-
-        # image request
-        else:
-            # check for enough IIIF parameters
-            if len(path_components) < 4:
-                raise ParseError('Invalid IIIF image request: %s' % url)
-
-            # pop off url portions as they are used so we can easily
-            # make use of leftover path to reconstruct the api endpoint
-            quality, fmt = path_basename.split('.')
-            rotation = path_components.pop()
-            size = path_components.pop()
-            region = path_components.pop()
-            image_id = path_components.pop()
-            opts.update({
-                'region': region,
-                'size': size,
-                'rotation': rotation,
-                'quality': quality,
-                'fmt': fmt
-            })
-
-        # construct the api endpoint url from the parsed url and whatever
-        # portions of the url path are leftover
-        # remove empty strings from the remaining path components
-        path_components = [p for p in path_components if p]
-        api_endpoint = '%s://%s/%s' % (
-            parsed_url.scheme, parsed_url.netloc,
-            '/'.join(path_components) if path_components else '')
-
-        # init and return instance
-        return cls(api_endpoint=api_endpoint, image_id=image_id, **opts)
-
-    def as_dict(self):
-        '''
-        Dictionary of with all image request options.
-        request parameters. Returns a dictionary with all image request
-        parameters parsed to their most granular level. Can be helpful
-        for acting logically on particular request parameters like height,
-        width, mirroring, etc.
-        '''
-        return OrderedDict([
-            ('region', self.region.as_dict()),
-            ('size', self.size.as_dict()),
-            ('rotation', self.rotation.as_dict()),
-            ('quality', self.image_options['quality']),
-            ('format', self.image_options['fmt'])
-        ])
+# Could raise a deprecation warning if we care
+#raise DeprecationWarning('piffle.iiif is deprecated; please use piffle.image')
diff --git a/piffle/image.py b/piffle/image.py
new file mode 100644
index 0000000..bda506c
--- /dev/null
+++ b/piffle/image.py
@@ -0,0 +1,721 @@
+# iiifclient
+# -*- coding: utf-8 -*-
+
+from collections import OrderedDict
+from urllib.parse import urlparse
+
+from cached_property import cached_property
+import requests
+
+
+class IIIFImageClientException(Exception):
+    '''IIIFImageClient custom exception class'''
+
+
+class ParseError(IIIFImageClientException):
+    '''Exception raised when an IIIF image could not be parsed'''
+
+
+# NOTE: possible image component base class?
+# commonalities so far: setting defaults on init / parse
+# handling exact matches like full/square? (but maybe only region/size),
+# validating options (and could add option type checking)
+
+
+class ImageRegion(object):
+    '''IIIF Image region. Intended to be used with :class:`IIIFImageClient`.
+    Can be initialized with related image object and region options.
+
+    When associated with an image, region is callable and will return
+    an updated image object with the modified region options.
+
+    :param img: :class:`IIFImageClient`
+    :param full: full region, defaults to true
+    :param square: square region, defaults to false
+    :param x: x coordinate
+    :param y: y coordinate
+    :param width: region width
+    :param height: region height
+    :param percent: region is a percentage
+    '''
+
+    # region options
+    options = OrderedDict([
+        ('full', False),
+        ('square', False),
+        ('x', None),
+        ('y', None),
+        ('width', None),
+        ('height', None),
+        ('percent', False)
+    ])
+
+    region_defaults = {
+        'full': True,
+        'square': False,
+        'x': None,
+        'y': None,
+        'width': None,
+        'height': None,
+        'percent': False
+    }
+
+    coords = ['x', 'y', 'width', 'height']
+
+    def __init__(self, img=None, **options):
+        self.img = img
+        self.options = self.region_defaults.copy()
+        if options:
+            self.set_options(**options)
+
+    def __call__(self, **options):
+        if self.img is not None:
+            img = self.img.get_copy()
+            img.region.set_options(**options)
+            return img
+
+    def set_options(self, **options):
+        '''Update region options.  Same parameters as initialiation.'''
+        allowed_options = list(self.options.keys())
+        # error if an unrecoganized option is specified
+        for key in options:
+            if key not in allowed_options:
+                raise IIIFImageClientException('Unknown option: %s' % key)
+
+        # error if some but not all coordinates are specified
+        # or if percentage is specified but not all coordinates are present
+        if (any([coord in options for coord in self.coords]) or
+           'percent' in options) and not \
+           all([coord in options for coord in self.coords]):
+            # partial region specified
+            raise IIIFImageClientException('Incomplete region specified')
+
+        # TODO: do we need to type checking? bool/int/float?
+
+        self.options.update(**options)
+        # if any non-full value is specified, set full to false
+        # NOTE: if e.g. square is specified but false, this is wrong
+        allowed_options.remove('full')
+        if any([(key in allowed_options and options[key])
+               for key in options.keys()]):
+            self.options['full'] = False
+
+    def as_dict(self):
+        '''Return region options as a dictionary'''
+        return self.options
+
+    def __str__(self):
+        '''Render region information in IIIF region format'''
+        if self.options['full']:
+            return 'full'
+        if self.options['square']:
+            return 'square'
+
+        coords = '%(x)g,%(y)g,%(width)g,%(height)g' % self.options
+        if self.options['percent']:
+            return 'pct:%s' % coords
+
+        return coords
+
+    def parse(self, region):
+        '''Parse an IIIF Image region string and update the current region'''
+
+        # reset to defaults before parsing
+        self.options = self.region_defaults.copy()
+
+        # full?
+        if region == 'full':
+            self.options['full'] = True
+            # return immediately
+            return
+
+        self.options['full'] = False
+
+        if region == 'square':
+            self.options['square'] = True
+            # return immediately
+            return self
+
+        # percent?
+        if "pct" in region:
+            self.options['percent'] = True
+            region = region.split("pct:")[1]
+
+        # split to dictionary
+        # if percentage type, cast to float
+        try:
+            if self.options['percent']:
+                coords = [float(region_c) for region_c in region.split(",")]
+            # else, force int
+            else:
+                coords = [int(region_c) for region_c in region.split(",")]
+        except ValueError:
+            # failure converting to integer or float
+            raise ParseError('Invalid region coordinates: %s' % region)
+
+        if len(coords) != 4:
+            raise ParseError('Invalid region coordinates: %s' % region)
+
+        x, y, width, height = coords
+        self.options.update({'x': x, 'y': y, 'width': width, 'height': height})
+
+    def canonicalize(self):
+        '''Canonicalize the current region options so that
+        serialization results in canonical format.'''
+        # From the spec:
+        #   “full” if the whole image is requested, (including a “square”
+        #    region of a square image), otherwise the x,y,w,h syntax.
+
+        if self.options['full']:
+            # nothing to do
+            return
+
+        # if already in x,y,w,h format - nothing to do
+        if not any([self.options['full'], self.options['square'],
+                    self.options['percent']]):
+            return
+
+        # possbly an error here for every other case if self.img is not set,
+        # since it's probably not possible to canonicalize without knowing
+        # image size  (any exceptions?)
+        if self.img is None:
+            raise IIIFImageClientException('Cannot canonicalize without image')
+
+        if self.options['square']:
+            # if image is square, then return full
+            if self.img is not None and \
+             self.img.image_width == self.img.image_height:
+                self.options['full'] = True
+                self.options['square'] = False
+                return
+
+            # otherwise convert to x,y,w,h
+            # from the spec:
+            #  The region is defined as an area where the width and height
+            #  are both equal to the length of the shorter dimension of the
+            #  complete image. The region may be positioned anywhere in the
+            #  longer dimension of the image content at the server’s
+            #  discretion, and centered is often a reasonable default.
+
+            # determine size of the short edge
+            short_edge = min(self.img.image_width, self.img.image_height)
+            width = height = short_edge
+            # calculate starting long edge point to center the square
+            if self.img.image_height == short_edge:
+                y = 0
+                x = (self.img.image_width - short_edge) / 2
+            else:
+                x = 0
+                y = (self.img.image_height - short_edge) / 2
+
+            self.options.update({'x': x, 'y': y, 'width': width,
+                                 'height': height, 'square': False})
+
+        if self.options['percent']:
+            # convert percentages to x,y,w,h
+            # From the spec:
+            #  The region to be returned is specified as a sequence of
+            #  percentages of the full image’s dimensions, as reported in
+            #  the image information document. Thus, x represents the number
+            #  of pixels from the 0 position on the horizontal axis, calculated
+            #  as a percentage of the reported width. w represents the width
+            #  of the region, also calculated as a percentage of the reported
+            #  width. The same applies to y and h respectively. These may be
+            #  floating point numbers.
+
+            # convert percentages to dimensions based on image size
+            self.options.update({
+                'percent': False,
+                'x': int((self.options['x']/100) * self.img.image_width),
+                'y': int((self.options['y']/100) * self.img.image_height),
+                'width': int((self.options['width']/100) * self.img.image_width),
+                'height': int((self.options['height']/100) * self.img.image_height)
+            })
+            return
+
+
+class ImageSize(object):
+    '''IIIF Image Size.  Intended to be used with :class:`IIIFImageClient`.
+    Can be initialized with related image object and size options.
+
+    When associated with an image, size is callable and will return
+    an updated image object with the modified size.
+
+    :param img: :class:`IIFImageClient`
+    :param width: optional width
+    :param height: optional height
+    :param percent: optional percent
+    :param exact: size should be exact (boolean, optional)
+    '''
+
+    # size options
+    options = OrderedDict([
+        ('full', False),
+        ('max', False),
+        ('width', None),
+        ('height', None),
+        ('percent', None),
+        ('exact', False)
+    ])
+
+    # NOTE: full is being deprecated and replaced with max;
+    # full is deprecated in 2.1 and will be removed for 3.0
+    # Eventually piffle will need to address that, maybe with some kind of
+    # support for selecting a particular version of the IIIF image spec.
+    # For now, default size is still full, and max and full are treated as
+    # separate modes.  A parsed url with max will return max, and a parsed
+    # url with full will return full, but that will probably change
+    # once the deprecated full is handled properly.
+
+    size_defaults = {
+        'full': True,
+        'max': False,
+        'width': None,
+        'height': None,
+        'percent': None,
+        'exact': False
+    }
+
+    def __init__(self, img=None, **options):
+        self.img = img
+        self.options = self.size_defaults.copy()
+        if options:
+            self.set_options(**options)
+
+    def __call__(self, **options):
+        if self.img is not None:
+            img = self.img.get_copy()
+            img.size.set_options(**options)
+            return img
+
+    def set_options(self, **options):
+        '''Update size options.  Same parameters as initialiation.'''
+        allowed_options = list(self.options.keys())
+        # error if an unrecoganized option is specified
+        for key in options:
+            if key not in allowed_options:
+                raise IIIFImageClientException('Unknown option: %s' % key)
+
+        # TODO: do we need to type checking? bool/int/float?
+
+        self.options.update(**options)
+        # if any non-full value is specified, set full to false
+        # NOTE: if e.g. square is specified but false, this is wrong
+        allowed_options.remove('full')
+        if any([key in allowed_options and options[key]
+                for key in options.keys()]):
+            self.options['full'] = False
+
+    def as_dict(self):
+        '''Return size options as a dictionary'''
+        return self.options
+
+    def __str__(self):
+        if self.options['full']:
+            return 'full'
+        if self.options['max']:
+            return 'max'
+        if self.options['percent']:
+            return 'pct:%g' % self.options['percent']
+
+        size = '%s,%s' % (self.options['width'] or '',
+                          self.options['height'] or '')
+        if self.options['exact']:
+            return '!%s' % size
+        return size
+
+    def parse(self, size):
+        # reset to defaults before parsing
+        self.options = self.size_defaults.copy()
+
+        # full?
+        if size == 'full':
+            self.options['full'] = True
+            return
+
+        # for any other case, full should be false
+        self.options['full'] = False
+
+        # max?
+        if size == 'max':
+            self.options['max'] = True
+            return
+
+        # percent?
+        if "pct" in size:
+            try:
+                self.options['percent'] = float(size.split(":")[1])
+                return
+            except ValueError:
+                raise ParseError('Error parsing size: %s' % size)
+
+        # exact?
+        if size.startswith('!'):
+            self.options['exact'] = True
+            size = size.lstrip('!')
+
+        # split width and height
+        width, height = size.split(",")
+        try:
+            if width != '':
+                self.options['width'] = int(width)
+            if height != '':
+                self.options['height'] = int(height)
+        except ValueError:
+            raise ParseError('Error parsing size: %s' % size)
+
+    def canonicalize(self):
+        '''Canonicalize the current size options so that
+        serialization results in canonical format.'''
+        # From the spec:
+        #   “full” if the default size is requested,
+        #   the w, syntax for images that should be scaled maintaining the
+        #   aspect ratio, and the w,h syntax for explicit sizes that change
+        #   the aspect ratio.
+        #   Note: The size keyword “full” will be replaced with “max” in
+        #   version 3.0
+
+        if self.options['full']:
+            # nothing to do
+            return
+
+        # possbly an error here for every other case if self.img is not set,
+        # since it's probably not possible to canonicalize without knowing
+        # image size  (any exceptions?)
+        if self.img is None:
+            raise IIIFImageClientException('Cannot canonicalize without image')
+
+        if self.options['percent']:
+            # convert percentage to w,h
+            scale = self.options['percent'] / 100
+            self.options.update({
+                'height': int(self.img.image_height * scale),
+                'width': int(self.img.image_width * scale),
+                'percent': None,
+            })
+            return
+
+        if self.options['exact']:
+            # from the spec:
+            #   The image content is scaled for the best fit such that the
+            #   resulting width and height are less than or equal to the
+            #   requested width and height. The exact scaling may be determined
+            #   by the service provider, based on characteristics including
+            #   image quality and system performance. The dimensions of the
+            #   returned image content are calculated to maintain the aspect
+            #   ratio of the extracted region.
+
+            # determine which edge results in a smaller scale
+            wscale = float(self.options['width']) / float(self.img.image_width)
+            hscale = float(self.options['height']) / float(self.img.image_height)
+            scale = min(wscale, hscale)
+            # use that scale on original image size, to preserve
+            # original aspect ratio
+            self.options.update({
+                'exact': False,
+                'height': int(scale * self.img.image_height),
+                'width': int(scale * self.img.image_width)
+            })
+            return
+
+        # if height only is specified (,h), convert to width only (w,)
+        if self.options['height'] and self.options['width'] is None:
+            # determine the scale in use, and convert from
+            # height only to width only
+            scale = float(self.options['height']) / float(self.img.image_height)
+            self.options.update({
+                'height': None,
+                'width': int(scale * self.img.image_width)
+            })
+
+
+class ImageRotation(object):
+    '''IIIF Image rotation Intended to be used with :class:`IIIFImageClient`.
+    Can be initialized with related image object and rotation options.
+
+    When associated with an image, rotation is callable and will return
+    an updated image object with the modified rotatoin options.
+
+    :param img: :class:`IIFImageClient`
+    :param degrees: degrees rotation, optional
+    :param mirrored: image should be mirrored (boolean, optional, default
+       is False)
+    '''
+
+    # rotation options
+    options = OrderedDict([
+        ('degrees', None),
+        ('mirrored', False),
+    ])
+
+    rotation_defaults = {
+        'degrees': 0,
+        'mirrored': False
+    }
+
+    def __init__(self, img=None, **options):
+        self.img = img
+        self.options = self.rotation_defaults.copy()
+        if options:
+            self.set_options(**options)
+
+    def __call__(self, **options):
+        if self.img is not None:
+            img = self.img.get_copy()
+            img.rotation.set_options(**options)
+            return img
+
+    def set_options(self, **options):
+        '''Update size options.  Same parameters as initialiation.'''
+        allowed_options = self.options.keys()
+        # error if an unrecoganized option is specified
+        for key in options:
+            if key not in allowed_options:
+                raise IIIFImageClientException('Unknown option: %s' % key)
+
+        # TODO: do we need to type checking? bool/int/float?
+
+        self.options.update(**options)
+
+    def as_dict(self):
+        '''Return rotation options as a dictionary'''
+        return self.options
+
+    def __str__(self):
+        return '%s%g' % ('!' if self.options['mirrored'] else '',
+                         self.options['degrees'])
+
+    def parse(self, rotation):
+        # reset to defaults before parsing
+        self.options = self.rotation_defaults.copy()
+
+        if str(rotation).startswith('!'):
+            self.options['mirrored'] = True
+            rotation = rotation.lstrip('!')
+
+        # rotation allows float
+        self.options['degrees'] = float(rotation)
+
+    def canonicalize(self):
+        '''Canonicalize the current region options so that
+        serialization results in canonical format.'''
+        # NOTE: explicitly including a canonicalize as a method to make it
+        # clear that this field supports canonicalization, but no work
+        # is needed since the existing render does the right things:
+        # - trim any trailing zeros in a decimal value
+        # - leading zero if less than 1
+        # - ! if mirrored, followed by integer if possible
+        return
+
+
+class IIIFImageClient(object):
+    '''Simple IIIF Image API client for generating IIIF image urls
+    in an object-oriented, pythonic fashion.  Can be extended,
+    when custom logic is needed to set the image id.  Provides
+    a fluid interface, so that IIIF methods can be chained, e.g.::
+
+        iiif_img.size(width=300).rotation(90).format('png')
+
+   Note that this returns a new image instance with the specified
+   options, and the original image will remain unchanged.
+
+    .. Note::
+
+        Method to set quality not yet available.
+    '''
+
+    api_endpoint = None
+    image_id = None
+    default_format = 'jpg'
+
+    # iiif defaults for each sections
+    image_defaults = {
+        'quality': 'default',  # color, gray, bitonal, default
+        'fmt': default_format
+    }
+    allowed_formats = ['jpg', 'tif', 'png', 'gif', 'jp2', 'pdf', 'webp']
+
+    def __init__(self, api_endpoint=None, image_id=None, region=None,
+                 size=None, rotation=None, quality=None, fmt=None):
+        self.image_options = self.image_defaults.copy()
+        # NOTE: using underscore to differenteate objects from methods
+        # but it could be reasonable to make objects public
+        self.region = ImageRegion(self)
+        self.size = ImageSize(self)
+        self.rotation = ImageRotation(self)
+
+        if api_endpoint is not None:
+            # remove any trailing slash to avoid duplicate slashes
+            self.api_endpoint = api_endpoint.rstrip('/')
+
+        # FIXME: image_id is not required on init to allow subclassing
+        # and customizing via get_image_id, but should probably cause
+        # an error if you attempt to serialize the url and it is not set
+        # (same for a few other options, probably, too...)
+        if image_id is not None:
+            self.image_id = image_id
+
+        # for now, if region option is specified parse as string
+        if region is not None:
+            self.region.parse(region)
+        if size is not None:
+            self.size.parse(size)
+        if rotation is not None:
+            self.rotation.parse(rotation)
+
+        if quality is not None:
+            self.image_options['quality'] = quality
+        if fmt is not None:
+            self.image_options['fmt'] = fmt
+
+    def get_image_id(self):
+        'Image id to be used in contructing urls'
+        return self.image_id
+
+    def __str__(self):
+        info = self.image_options.copy()
+        info.update({
+            'endpoint': self.api_endpoint,
+            'id': self.get_image_id(),
+            'region': str(self.region),
+            'size': str(self.size),
+            'rot': str(self.rotation)
+        })
+        return '%(endpoint)s/%(id)s/%(region)s/%(size)s/%(rot)s/%(quality)s.%(fmt)s' % info
+
+    def __repr__(self):
+        return '<IIIFImageClient %s>' % self.get_image_id()
+        # include non-defaults?
+
+    def info(self):
+        'JSON info url'
+        return '%(endpoint)s/%(id)s/info.json' % {
+            'endpoint': self.api_endpoint,
+            'id': self.get_image_id(),
+        }
+
+    @cached_property
+    def image_info(self):
+        'Retrieve image information provided as JSON at info url'
+        resp = requests.get(self.info())
+        if resp.status_code == requests.codes.ok:
+            return resp.json()
+
+        resp.raise_for_status()
+
+    @property
+    def image_width(self):
+        'Image width as reported in :attr:`image_info`'
+        return self.image_info['width']
+
+    @property
+    def image_height(self):
+        'Image height as reported in :attr:`image_info`'
+        return self.image_info['height']
+
+    def get_copy(self):
+        'Get a clone of the current settings for modification.'
+        clone = self.__class__(self.api_endpoint, self.image_id,
+                               **self.image_options)
+        # copy region, size, and rotation - no longer included in
+        # image_options dict
+        clone.region.set_options(**self.region.as_dict())
+        clone.size.set_options(**self.size.as_dict())
+        clone.rotation.set_options(**self.rotation.as_dict())
+        return clone
+
+    # method to set quality not yet implemented
+
+    def format(self, image_format):
+        'Set output image format'
+        if image_format not in self.allowed_formats:
+            raise IIIFImageClientException('Image format %s unknown' % image_format)
+        img = self.get_copy()
+        img.image_options['fmt'] = image_format
+        return img
+
+    def canonicalize(self):
+        '''Canonicalize the URI'''
+        img = self.get_copy()
+        img.region.canonicalize()
+        img.size.canonicalize()
+        img.rotation.canonicalize()
+        return img
+
+    @classmethod
+    def init_from_url(cls, url):
+        '''Init ImageClient using Image API parameters from URI.  Detect
+        image vs. info request. Can count reliably from the end of the URI
+        backwards, but cannot assume how many slashes make up the api_endpoint.
+        Returns new instance of IIIFImageClient.
+        Per http://iiif.io/api/image/2.0/#image-request-uri-syntax, using
+        slashes to parse URI'''
+
+        # first parse as a url
+        parsed_url = urlparse(url)
+        # then split the path on slashes
+        # and remove any empty strings
+        path_components = [path for path in parsed_url.path.split('/') if path]
+        if not path_components:
+            raise ParseError('Invalid IIIF image url: %s'
+                             % url)
+        # pop off last portion of the url to determine if this is an info url
+        path_basename = path_components.pop()
+        opts = {}
+
+        # info request
+        if path_basename == 'info.json':
+            # NOTE: this is unlikely to happen; more likely, if information is
+            # missing, we will misinterpret the api endpoint or the image id
+            if len(path_components) < 1:
+                raise ParseError('Invalid IIIF image information url: %s'
+                                 % url)
+            image_id = path_components.pop()
+
+        # image request
+        else:
+            # check for enough IIIF parameters
+            if len(path_components) < 4:
+                raise ParseError('Invalid IIIF image request: %s' % url)
+
+            # pop off url portions as they are used so we can easily
+            # make use of leftover path to reconstruct the api endpoint
+            quality, fmt = path_basename.split('.')
+            rotation = path_components.pop()
+            size = path_components.pop()
+            region = path_components.pop()
+            image_id = path_components.pop()
+            opts.update({
+                'region': region,
+                'size': size,
+                'rotation': rotation,
+                'quality': quality,
+                'fmt': fmt
+            })
+
+        # construct the api endpoint url from the parsed url and whatever
+        # portions of the url path are leftover
+        # remove empty strings from the remaining path components
+        path_components = [p for p in path_components if p]
+        api_endpoint = '%s://%s/%s' % (
+            parsed_url.scheme, parsed_url.netloc,
+            '/'.join(path_components) if path_components else '')
+
+        # init and return instance
+        return cls(api_endpoint=api_endpoint, image_id=image_id, **opts)
+
+    def as_dict(self):
+        '''
+        Dictionary of with all image request options.
+        request parameters. Returns a dictionary with all image request
+        parameters parsed to their most granular level. Can be helpful
+        for acting logically on particular request parameters like height,
+        width, mirroring, etc.
+        '''
+        return OrderedDict([
+            ('region', self.region.as_dict()),
+            ('size', self.size.as_dict()),
+            ('rotation', self.rotation.as_dict()),
+            ('quality', self.image_options['quality']),
+            ('format', self.image_options['fmt'])
+        ])
diff --git a/piffle/presentation.py b/piffle/presentation.py
new file mode 100644
index 0000000..1cd7754
--- /dev/null
+++ b/piffle/presentation.py
@@ -0,0 +1,140 @@
+import json
+import os.path
+import urllib
+
+from attrdict import AttrMap
+
+import requests
+
+
+class IIIFException(Exception):
+    '''Custom exception for IIIF errors'''
+
+
+def get_iiif_url(url):
+    '''Wrapper around :meth:`requests.get` to support conditionally
+    adding an auth tokens or other parameters.'''
+    request_options = {}
+    # TODO: need some way of configuring hooks for e.g. setting auth tokens
+    return requests.get(url, **request_options)
+
+
+class IIIFPresentation(AttrMap):
+    ''':class:`attrdict.AttrMap` subclass for read access to IIIF Presentation
+    content'''
+
+    # TODO: document sample use, e.g. @ fields
+
+    at_fields = ['type', 'id', 'context']
+
+    @classmethod
+    def from_file(cls, path):
+        '''Iniitialize :class:`IIIFPresentation` from a file.'''
+        with open(path) as manifest:
+            data = json.loads(manifest.read())
+        return cls(data)
+
+    @classmethod
+    def from_url(cls, uri):
+        '''Iniitialize :class:`IIIFPresentation` from a URL.
+
+        :raises: :class:`IIIFException` if URL is not retrieved successfully,
+            if the response is not JSON content, or if the JSON cannot be parsed.
+        '''
+        response = get_iiif_url(uri)
+        if response.status_code == requests.codes.ok:
+            try:
+                return cls(response.json())
+            except json.decoder.JSONDecodeError as err:
+                # if json fails, two possibilities:
+                # - we didn't actually get json (e.g. redirect for auth)
+                if 'application/json' not in response.headers['content-type']:
+                    raise IIIFException('No JSON found at %s' % uri)
+                # - there is something wrong with the json
+                raise IIIFException('Error parsing JSON for %s: %s' %
+                                    (uri, err))
+
+        raise IIIFException(
+            'Error retrieving manifest at %s: %s %s' %
+            (uri, response.status_code, response.reason))
+
+    @classmethod
+    def is_url(cls, url):
+        '''Utility method to check if a path is a url or file'''
+        return urllib.parse.urlparse(url).scheme != ""
+
+    @classmethod
+    def from_file_or_url(cls, path):
+        '''Iniitialize :class:`IIIFPresentation` from a file or a url.'''
+        if os.path.isfile(path):
+            return cls.from_file(path)
+        elif cls.is_url(path):
+            return cls.from_url(path)
+        else:
+            raise IIIFException('File not found: %s' % path)
+
+    @classmethod
+    def short_id(cls, uri):
+        '''Generate a short id from full manifest/canvas uri identifiers
+        for use in local urls.  Logic is based on the recommended
+        url pattern from the IIIF Presentation 2.0 specification.'''
+
+        # shortening should work reliably for uris that follow
+        # recommended url patterns from the spec
+        # http://iiif.io/api/presentation/2.0/#a-summary-of-recommended-uri-patterns
+        #   manifest:  {scheme}://{host}/{prefix}/{identifier}/manifest
+        #   canvas: {scheme}://{host}/{prefix}/{identifier}/canvas/{name}
+
+        # remove trailing /manifest at the end of the url, if present
+        if uri.endswith('/manifest'):
+            uri = uri[:-len('/manifest')]
+        # split on slashes and return the last portion
+        return uri.split('/')[-1]
+
+    def __getattr__(self, key):
+        """
+        Access an item as an attribute.
+        """
+        # override getattr to allow use of keys with leading @,
+        # which are otherwise not detected as present and not valid
+        at_key = self._handle_at_keys(key)
+        if key not in self or \
+           (key not in self.at_fields and at_key not in self) or \
+           not self._valid_name(key):
+            raise AttributeError(
+                "'{cls}' instance has no attribute '{name}'".format(
+                    cls=self.__class__.__name__, name=key
+                )
+            )
+        return self._build(self[key])
+
+    def _handle_at_keys(self, key):
+        if key in self.at_fields:
+            key = '@%s' % key
+        return key
+
+    def __getitem__(self, key):
+        """
+        Access a value associated with a key.
+        """
+        return self._mapping[self._handle_at_keys(key)]
+
+    def __setitem__(self, key, value):
+        """
+        Add a key-value pair to the instance.
+        """
+        self._mapping[self._handle_at_keys(key)] = value
+
+    def __delitem__(self, key):
+        """
+        Delete a key-value pair
+        """
+        del self._mapping[self._handle_at_keys(key)]
+
+    @property
+    def first_label(self):
+        # label can be a string or list of strings
+        if isinstance(self.label, str):
+            return self.label
+        else:
+            return self.label[0]
diff --git a/setup.py b/setup.py
index b7e2dcf..59104f1 100755
--- a/setup.py
+++ b/setup.py
@@ -19,26 +19,24 @@
     'Natural Language :: English',
     'Operating System :: OS Independent',
     'Programming Language :: Python',
-    'Programming Language :: Python :: 2',
-    'Programming Language :: Python :: 2.7',
     'Programming Language :: Python :: 3',
-    'Programming Language :: Python :: 3.4',
-    'Programming Language :: Python :: 3.5',
     'Programming Language :: Python :: 3.6',
+    'Programming Language :: Python :: 3.7',
+    'Programming Language :: Python :: 3.8',
     'Topic :: Software Development :: Libraries :: Python Modules',
 ]
 
-test_requirements = ['pytest>=3.6', 'pytest-cov', 'mock']
+test_requirements = ['pytest>=3.6', 'pytest-cov']
 
 setup(
     name='piffle',
     version=piffle.__version__,
-    author='Emory University Libraries',
-    author_email='libsysdev-l@listserv.cc.emory.edu',
-    url='https://github.com/emory-lits-labs/piffle',
+    author='The Center for Digital Humanities at Princeton',
+    author_email='cdhdevteam@princeton.edu',
+    url='https://github.com/princeton-cdh/piffle',
     license='Apache License, Version 2.0',
     packages=find_packages(),
-    install_requires=['requests', 'cached-property', 'six', 'future'],
+    install_requires=['requests', 'cached-property', 'attrdict'],
     setup_requires=['pytest-runner'],
     tests_require=test_requirements,
     extras_require={


Test patch:
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
new file mode 100644
index 0000000..f772172
--- /dev/null
+++ b/.github/workflows/unit_tests.yml
@@ -0,0 +1,71 @@
+name: unit tests
+
+on:
+  push: # run on every push or PR to any branch
+  pull_request:
+  schedule: # run automatically on main branch each Tuesday at 11am
+    - cron: "0 16 * * 2"
+
+jobs:
+  python-unit:
+    name: Python unit tests
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python: [3.6, 3.7, 3.8]
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+
+      - name: Setup Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python }}
+
+      # We base the python cache on the hash of all requirements files, so that
+      # if any change, the cache is invalidated.
+      - name: Cache pip
+        uses: actions/cache@v2
+        with:
+          path: ~/.cache/pip
+          key: pip-${{ hashFiles('setup.py') }}
+          restore-keys: |
+            pip-${{ hashFiles('setup.py') }}
+            pip-
+
+      - name: Install package with dependencies
+        run: |
+          pip install -e .
+          pip install -e '.[test]'
+          pip install codecov
+
+      - name: Run pytest
+        run: py.test --cov=piffle --cov-report=xml
+
+      - name: Upload test coverage to Codecov
+        uses: codecov/codecov-action@v1
+
+  # Set the color of the slack message used in the next step based on the
+      # status of the build: "danger" for failure, "good" for success,
+      # "warning" for error
+      - name: Set Slack message color based on build status
+        if: ${{ always() }}
+        env:
+          JOB_STATUS: ${{ job.status }}
+        run: echo "SLACK_COLOR=$(if [ "$JOB_STATUS" == "success" ]; then echo "good"; elif [ "$JOB_STATUS" == "failure" ]; then echo "danger"; else echo "warning"; fi)" >> $GITHUB_ENV
+
+      # Send a message to slack to report the build status. The webhook is stored
+      # at the organization level and available to all repositories. Only run on
+      # scheduled builds & pushes, since PRs automatically report to Slack.
+      - name: Report status to Slack
+        uses: rtCamp/action-slack-notify@master
+        if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'push') }}
+        continue-on-error: true
+        env:
+          SLACK_COLOR: ${{ env.SLACK_COLOR }}
+          SLACK_WEBHOOK: ${{ secrets.ACTIONS_SLACK_WEBHOOK }}
+          SLACK_TITLE: "Workflow `${{ github.workflow }}` (python ${{ matrix.python }}): ${{ job.status }}"
+          SLACK_MESSAGE: "Run <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|#${{ github.run_number }}> on <https://github.com/${{ github.repository }}/|${{ github.repository }}@${{ github.ref }}>"
+          SLACK_FOOTER: "<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|View commit>"
+          MSG_MINIMAL: true # use compact slack message format
diff --git a/tests/fixtures/chto-manifest.json b/tests/fixtures/chto-manifest.json
new file mode 100644
index 0000000..9ce8de9
--- /dev/null
+++ b/tests/fixtures/chto-manifest.json
@@ -0,0 +1,899 @@
+{
+  "@context": "http://iiif.io/api/presentation/2/context.json",
+  "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest",
+  "@type": "sc:Manifest",
+  "label": [
+    "Chto my stroim : Tetrad\u02b9 s kartinkami"
+  ],
+  "viewingHint": "paged",
+  "viewingDirection": "left-to-right",
+  "sequences": [
+    {
+      "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/sequence/normal",
+      "@type": "sc:Sequence",
+      "canvases": [
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d",
+          "@type": "sc:Canvas",
+          "label": "image 1",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p02871v98d-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6470,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d"
+            }
+          ],
+          "width": 6470,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p02871v98d/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722",
+          "@type": "sc:Canvas",
+          "label": "image 2",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/prr172w722-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pr%2Fr1%2F72%2Fw7%2F22-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6289,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pr%2Fr1%2F72%2Fw7%2F22-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722"
+            }
+          ],
+          "width": 6289,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/prr172w722/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250",
+          "@type": "sc:Canvas",
+          "label": "image 3",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/png452h250-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pn%2Fg4%2F52%2Fh2%2F50-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pn%2Fg4%2F52%2Fh2%2F50-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/png452h250/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204",
+          "@type": "sc:Canvas",
+          "label": "image 4",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pcf95k9204-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pc%2Ff9%2F5k%2F92%2F04-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pc%2Ff9%2F5k%2F92%2F04-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pcf95k9204/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m",
+          "@type": "sc:Canvas",
+          "label": "image 5",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pvt151j60m-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pv%2Ft1%2F51%2Fj6%2F0m-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pv%2Ft1%2F51%2Fj6%2F0m-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pvt151j60m/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187",
+          "@type": "sc:Canvas",
+          "label": "image 6",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p08613m187-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F86%2F13%2Fm1%2F87-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F86%2F13%2Fm1%2F87-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p08613m187/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m",
+          "@type": "sc:Canvas",
+          "label": "image 7",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/p7p88df99m-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/p7%2Fp8%2F8d%2Ff9%2F9m-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/p7%2Fp8%2F8d%2Ff9%2F9m-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/p7p88df99m/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f",
+          "@type": "sc:Canvas",
+          "label": "image 8",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph702r431f-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F70%2F2r%2F43%2F1f-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F70%2F2r%2F43%2F1f-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph702r431f/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h",
+          "@type": "sc:Canvas",
+          "label": "image 9",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pth83n019h-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pt%2Fh8%2F3n%2F01%2F9h-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pt%2Fh8%2F3n%2F01%2F9h-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pth83n019h/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277",
+          "@type": "sc:Canvas",
+          "label": "image 10",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/phx11zd277-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fx1%2F1z%2Fd2%2F77-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fx1%2F1z%2Fd2%2F77-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/phx11zd277/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070",
+          "@type": "sc:Canvas",
+          "label": "image 11",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ppn89f6070-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pp%2Fn8%2F9f%2F60%2F70-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pp%2Fn8%2F9f%2F60%2F70-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ppn89f6070/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017",
+          "@type": "sc:Canvas",
+          "label": "image 12",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pqv33sx017-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pq%2Fv3%2F3s%2Fx0%2F17-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 7200,
+                "height": 4136,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pq%2Fv3%2F3s%2Fx0%2F17-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017"
+            }
+          ],
+          "width": 7200,
+          "height": 4136,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pqv33sx017/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q",
+          "@type": "sc:Canvas",
+          "label": "image 13",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pjm215n89q-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2Fm2%2F15%2Fn8%2F9q-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2Fm2%2F15%2Fn8%2F9q-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pjm215n89q/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419",
+          "@type": "sc:Canvas",
+          "label": "image 14",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pwm118n419-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pw%2Fm1%2F18%2Fn4%2F19-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6356,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pw%2Fm1%2F18%2Fn4%2F19-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419"
+            }
+          ],
+          "width": 6356,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pwm118n419/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x",
+          "@type": "sc:Canvas",
+          "label": "image 15",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph128pc97x-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F7x-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F7x-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph128pc97x/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w",
+          "@type": "sc:Canvas",
+          "label": "image 16",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pbk129890w-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pb%2Fk1%2F29%2F89%2F0w-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6287,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pb%2Fk1%2F29%2F89%2F0w-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w"
+            }
+          ],
+          "width": 6287,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pbk129890w/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902",
+          "@type": "sc:Canvas",
+          "label": "image 17",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/psq87cs902-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fq8%2F7c%2Fs9%2F02-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fq8%2F7c%2Fs9%2F02-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/psq87cs902/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987",
+          "@type": "sc:Canvas",
+          "label": "image 18",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/ph128pc987-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F87-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6287,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2F12%2F8p%2Fc9%2F87-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987"
+            }
+          ],
+          "width": 6287,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/ph128pc987/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000",
+          "@type": "sc:Canvas",
+          "label": "image 19",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/phm50vs000-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fm5%2F0v%2Fs0%2F00-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ph%2Fm5%2F0v%2Fs0%2F00-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/phm50vs000/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750",
+          "@type": "sc:Canvas",
+          "label": "image 20",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pj09908750-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2F09%2F90%2F87%2F50-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6289,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pj%2F09%2F90%2F87%2F50-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750"
+            }
+          ],
+          "width": 6289,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pj09908750/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q",
+          "@type": "sc:Canvas",
+          "label": "image 21",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/psn00bx49q-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fn0%2F0b%2Fx4%2F9q-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 7200,
+                "height": 4077,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/ps%2Fn0%2F0b%2Fx4%2F9q-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q"
+            }
+          ],
+          "width": 7200,
+          "height": 4077,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/psn00bx49q/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        },
+        {
+          "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v",
+          "@type": "sc:Canvas",
+          "label": "image 22",
+          "images": [
+            {
+              "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/annotation/pzg64vk52v-image",
+              "@type": "oa:Annotation",
+              "motivation": "sc:painting",
+              "resource": {
+                "@id": "https://libimages1.princeton.edu/loris/plum_prod/pz%2Fg6%2F4v%2Fk5%2F2v-intermediate_file.jp2/full/!600,600/0/default.jpg",
+                "@type": "dcterms:Image",
+                "format": "image/jpeg",
+                "width": 6290,
+                "height": 7200,
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json",
+                  "@id": "https://libimages1.princeton.edu/loris/plum_prod/pz%2Fg6%2F4v%2Fk5%2F2v-intermediate_file.jp2",
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              },
+              "on": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v"
+            }
+          ],
+          "width": 6290,
+          "height": 7200,
+          "otherContent": [
+            {
+              "@id": "https://plum.princeton.edu/concern/container/ph415q7581/file_sets/pzg64vk52v/text",
+              "@type": "sc:AnnotationList",
+              "label": "Text of this Page"
+            }
+          ]
+        }
+      ],
+      "viewingHint": "paged",
+      "rendering": {
+        "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/pdf/gray",
+        "label": "Download as PDF",
+        "format": "application/pdf"
+      }
+    }
+  ],
+  "structures": [
+    {
+      "@id": "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/range/g69883452374360",
+      "@type": "sc:Range",
+      "label": "Logical",
+      "viewingHint": "top",
+      "canvases": [
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/prr172w722",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/png452h250",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pcf95k9204",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pvt151j60m",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p08613m187",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p7p88df99m",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph702r431f",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pth83n019h",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phx11zd277",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ppn89f6070",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pqv33sx017",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pjm215n89q",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pwm118n419",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc97x",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pbk129890w",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psq87cs902",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/ph128pc987",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/phm50vs000",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pj09908750",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/psn00bx49q",
+        "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/pzg64vk52v"
+      ]
+    }
+  ],
+  "metadata": [
+    {
+      "label": "Creator",
+      "value": [
+        "Savel\u02b9ev, L. (Leonid), 1904-1941"
+      ]
+    },
+    {
+      "label": "Publisher",
+      "value": [
+        "[Leningrad ; Moskva] : Gosudarstvennoe izdatel\u02b9stvo, 1930."
+      ]
+    },
+    {
+      "label": "Date created",
+      "value": [
+        "1930-01-01T00:00:00Z"
+      ]
+    },
+    {
+      "label": "Subject",
+      "value": [
+        "Industrialization\u2014Soviet Union\u2014Juvenile literature",
+        "Building\u2014Soviet Union\u2014Juvenile literature",
+        "Natural resources\u2014Soviet Union\u2014Juvenile literature"
+      ]
+    },
+    {
+      "label": "Language",
+      "value": [
+        "rus"
+      ]
+    },
+    {
+      "label": "Identifier",
+      "value": [
+        "ark:/88435/7p88ck29d"
+      ]
+    },
+    {
+      "label": "Replaces",
+      "value": [
+        "pudl0127/4765261"
+      ]
+    },
+    {
+      "label": "Format",
+      "value": "Book"
+    },
+    {
+      "label": "Extent",
+      "value": [
+        "[20] p. : color ill. ; 23 cm."
+      ]
+    },
+    {
+      "label": "Call number",
+      "value": [
+        "Pams / NR 20 / Cyrillic / Box 15 9331"
+      ]
+    },
+    {
+      "label": "Author",
+      "value": [
+        "Savel\u02b9ev, L. (Leonid), 1904-1941"
+      ]
+    },
+    {
+      "label": "Illustrator",
+      "value": [
+        "Tambi, V."
+      ]
+    },
+    {
+      "label": "Collection",
+      "value": [
+        "Soviet Era Books for Children and Youth",
+        "Treasures of the Cotsen Collection"
+      ]
+    }
+  ],
+  "thumbnail": {
+     "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2/full/!200,150/0/default.jpg",
+     "service": {
+        "@context": "http://iiif.io/api/image/2/context.json",
+        "@id": "https://libimages1.princeton.edu/loris/plum_prod/p0%2F28%2F71%2Fv9%2F8d-intermediate_file.jp2",
+        "profile": "http://iiif.io/api/image/2/level2.json"
+     }
+   },
+  "seeAlso": {
+    "@id": "https://bibdata.princeton.edu/bibliographic/4765261/jsonld",
+    "format": "application/ld+json"
+  },
+  "license": "http://rightsstatements.org/vocab/NKC/1.0/",
+  "logo": "https://example.com/logo.png"
+}
\ No newline at end of file
diff --git a/tests/test_piffle.py b/tests/test_image.py
similarity index 61%
rename from tests/test_piffle.py
rename to tests/test_image.py
index be9cd32..a7a0f4b 100644
--- a/tests/test_piffle.py
+++ b/tests/test_image.py
@@ -1,9 +1,9 @@
-from piffle import iiif
-from mock import patch
-import six
+from unittest.mock import patch
 import pytest
 import requests
 
+from piffle import image
+
 
 api_endpoint = 'http://imgserver.co'
 image_id = 'img1'
@@ -27,7 +27,7 @@
 }
 
 sample_image_info = {
-    '@context': "http://iiif.io/api/image/2/context.json",
+    '@context': "http://image.io/api/image/2/context.json",
     '@id': VALID_URLS['simple'],
     'height': 3039,
     'width': 2113,
@@ -35,8 +35,8 @@
 
 
 def get_test_imgclient():
-    return iiif.IIIFImageClient(api_endpoint=api_endpoint,
-                                image_id=image_id)
+    return image.IIIFImageClient(api_endpoint=api_endpoint,
+                                 image_id=image_id)
 
 
 class TestIIIFImageClient:
@@ -45,15 +45,15 @@ def test_defaults(self):
         img = get_test_imgclient()
         # default image url
         assert '%s/%s/full/full/0/default.jpg' % (api_endpoint, image_id) \
-               == six.text_type(img)
+               == str(img)
         # info url
         assert '%s/%s/info.json' % (api_endpoint, image_id) \
-               == six.text_type(img.info())
+               == str(img.info())
 
     def test_outputs(self):
         img = get_test_imgclient()
         # str and unicode should be equivalent
-        assert six.text_type(img.info()) == six.text_type(str(img.info()))
+        assert str(img.info()) == str(str(img.info()))
         # repr should have class and image id
         assert 'IIIFImageClient' in repr(img)
         assert img.get_image_id() in repr(img)
@@ -61,7 +61,7 @@ def test_outputs(self):
     def test_init_opts(self):
         test_opts = {'region': '2560,2560,256,256', 'size': '256,',
                      'rotation': '90', 'quality': 'color', 'fmt': 'png'}
-        img = iiif.IIIFImageClient(api_endpoint=api_endpoint,
+        img = image.IIIFImageClient(api_endpoint=api_endpoint,
                                    image_id=image_id, **test_opts)
         assert img.api_endpoint == api_endpoint
         assert img.image_id == image_id
@@ -70,37 +70,37 @@ def test_init_opts(self):
         del expected_img_opts['size']
         del expected_img_opts['rotation']
         assert img.image_options == expected_img_opts
-        assert six.text_type(img.region) == test_opts['region']
-        assert six.text_type(img.size) == test_opts['size']
-        assert six.text_type(img.rotation) == test_opts['rotation']
+        assert str(img.region) == test_opts['region']
+        assert str(img.size) == test_opts['size']
+        assert str(img.rotation) == test_opts['rotation']
 
         # TODO: should parse/verify options on init
-        # with pytest.raises(iiif.IIIFImageClientException):
-        #     img = iiif.IIIFImageClient(api_endpoint=api_endpoint, fmt='bogus')
+        # with pytest.raises(image.IIIFImageClientException):
+        #     img = image.IIIFImageClient(api_endpoint=api_endpoint, fmt='bogus')
 
     def test_size(self):
         img = get_test_imgclient()
         width, height, percent = 100, 150, 50
         # width only
         assert '%s/%s/full/%s,/0/default.jpg' % (api_endpoint, image_id, width)\
-            == six.text_type(img.size(width=width))
+            == str(img.size(width=width))
 
         # height only
         assert '%s/%s/full/,%s/0/default.jpg' % \
                (api_endpoint, image_id, height) == \
-               six.text_type(img.size(height=height))
+               str(img.size(height=height))
         # width and height
         assert '%s/%s/full/%s,%s/0/default.jpg' % \
                (api_endpoint, image_id, width, height) == \
-               six.text_type(img.size(width=width, height=height))
+               str(img.size(width=width, height=height))
         # exact width and height
         assert '%s/%s/full/!%s,%s/0/default.jpg' % \
                (api_endpoint, image_id, width, height) == \
-               six.text_type(img.size(width=width, height=height, exact=True))
+               str(img.size(width=width, height=height, exact=True))
         # percent
         assert '%s/%s/full/pct:%s/0/default.jpg' % \
                (api_endpoint, image_id, percent) == \
-               six.text_type(img.size(percent=percent))
+               str(img.size(percent=percent))
 
     def test_region(self):
         # region options passed through to region object and output
@@ -108,15 +108,15 @@ def test_region(self):
         x, y, width, height = 5, 10, 100, 150
         assert '%s/%s/%s,%s,%s,%s/full/0/default.jpg' % \
                (api_endpoint, image_id, x, y, width, height) \
-            == six.text_type(img.region(x=x, y=y, width=width, height=height))
+            == str(img.region(x=x, y=y, width=width, height=height))
 
     def test_rotation(self):
         # rotation options passed through to region object and output
         img = get_test_imgclient()
         assert '%s/%s/full/full/90/default.jpg' % \
                (api_endpoint, image_id) \
-            == six.text_type(img.rotation(degrees=90))
-        with pytest.raises(iiif.IIIFImageClientException):
+            == str(img.rotation(degrees=90))
+        with pytest.raises(image.IIIFImageClientException):
             img.rotation(foo='bar')
 
     def test_format(self):
@@ -124,11 +124,11 @@ def test_format(self):
         png = img.format('png')
         jpg = img.format('jpg')
         gif = img.format('gif')
-        assert six.text_type(png).endswith('.png')
-        assert six.text_type(jpg).endswith('.jpg')
-        assert six.text_type(gif).endswith('.gif')
+        assert str(png).endswith('.png')
+        assert str(jpg).endswith('.jpg')
+        assert str(gif).endswith('.gif')
 
-        with pytest.raises(iiif.IIIFImageClientException):
+        with pytest.raises(image.IIIFImageClientException):
             img.format('bogus')
 
     def test_combine_options(self):
@@ -137,72 +137,72 @@ def test_combine_options(self):
         fmt = 'png'
         assert '%s/%s/full/%s,/0/default.%s' % \
                (api_endpoint, image_id, width, fmt)\
-            == six.text_type(img.size(width=width).format(fmt))
+            == str(img.size(width=width).format(fmt))
 
         img = get_test_imgclient()
         x, y, width, height = 5, 10, 100, 150
         assert '%s/%s/%s,%s,%s,%s/full/0/default.%s' % \
                (api_endpoint, image_id, x, y, width, height, fmt) \
-            == six.text_type(img.region(x=x, y=y,
+            == str(img.region(x=x, y=y,
                                   width=width, height=height).format(fmt))
 
         assert '%s/%s/%s,%s,%s,%s/%s,/0/default.%s' % \
                (api_endpoint, image_id, x, y, width, height, width, fmt) \
-            == six.text_type(img.size(width=width)
+            == str(img.size(width=width)
                           .region(x=x, y=y, width=width, height=height)
                           .format(fmt))
         rotation = 90
         assert '%s/%s/%s,%s,%s,%s/%s,/%s/default.%s' % \
                (api_endpoint, image_id, x, y, width, height, width, rotation, fmt) \
-            == six.text_type(img.size(width=width)
+            == str(img.size(width=width)
                           .region(x=x, y=y, width=width, height=height)
                           .rotation(degrees=90)
                           .format(fmt))
 
         # original image object should be unchanged, and still show defaults
         assert '%s/%s/full/full/0/default.jpg' % (api_endpoint, image_id) \
-               == six.text_type(img)
+               == str(img)
 
     def test_init_from_url(self):
         # well-formed
         # - info url
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['info'])
-        assert isinstance(img, iiif.IIIFImageClient)
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['info'])
+        assert isinstance(img, image.IIIFImageClient)
         assert img.image_id == image_id
         # round trip back to original url
-        assert six.text_type(img.info()) == VALID_URLS['info']
+        assert str(img.info()) == VALID_URLS['info']
         assert img.api_endpoint == api_endpoint
         # -info with more complex endpoint base url
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['info-loris'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['info-loris'])
         assert img.api_endpoint == '%s/loris' % api_endpoint
         # - image
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
-        assert isinstance(img, iiif.IIIFImageClient)
-        assert six.text_type(img) == VALID_URLS['simple']
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['complex'])
-        assert six.text_type(img) == VALID_URLS['complex']
-        assert isinstance(img, iiif.IIIFImageClient)
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['exact'])
-        assert six.text_type(img) == VALID_URLS['exact']
-        assert isinstance(img, iiif.IIIFImageClient)
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        assert isinstance(img, image.IIIFImageClient)
+        assert str(img) == VALID_URLS['simple']
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['complex'])
+        assert str(img) == VALID_URLS['complex']
+        assert isinstance(img, image.IIIFImageClient)
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['exact'])
+        assert str(img) == VALID_URLS['exact']
+        assert isinstance(img, image.IIIFImageClient)
         assert img.size.options['exact'] is True
 
         # malformed
-        with pytest.raises(iiif.ParseError):
-            img = iiif.IIIFImageClient.init_from_url(INVALID_URLS['info'])
-        with pytest.raises(iiif.ParseError):
-            iiif.IIIFImageClient.init_from_url(INVALID_URLS['simple'])
-        with pytest.raises(iiif.ParseError):
-            iiif.IIIFImageClient.init_from_url(INVALID_URLS['complex'])
-        with pytest.raises(iiif.ParseError):
-            iiif.IIIFImageClient.init_from_url(INVALID_URLS['bad_size'])
-        with pytest.raises(iiif.ParseError):
-            iiif.IIIFImageClient.init_from_url(INVALID_URLS['bad_region'])
-        with pytest.raises(iiif.ParseError):
-            iiif.IIIFImageClient.init_from_url('http://info.json')
+        with pytest.raises(image.ParseError):
+            img = image.IIIFImageClient.init_from_url(INVALID_URLS['info'])
+        with pytest.raises(image.ParseError):
+            image.IIIFImageClient.init_from_url(INVALID_URLS['simple'])
+        with pytest.raises(image.ParseError):
+            image.IIIFImageClient.init_from_url(INVALID_URLS['complex'])
+        with pytest.raises(image.ParseError):
+            image.IIIFImageClient.init_from_url(INVALID_URLS['bad_size'])
+        with pytest.raises(image.ParseError):
+            image.IIIFImageClient.init_from_url(INVALID_URLS['bad_region'])
+        with pytest.raises(image.ParseError):
+            image.IIIFImageClient.init_from_url('http://info.json')
 
     def test_as_dicts(self):
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['complex'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['complex'])
         assert img.as_dict() == {
             'region': {
                 'full': False,
@@ -229,7 +229,7 @@ def test_as_dicts(self):
             'format': 'jpg'
         }
 
-    @patch('piffle.iiif.requests')
+    @patch('piffle.image.requests')
     def test_image_info(self, mockrequests):
         # test image info logic by mocking requests
         mockrequests.codes.ok = requests.codes.ok
@@ -238,57 +238,57 @@ def test_image_info(self, mockrequests):
         mockresponse.json.return_value = sample_image_info
 
         # valid response
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
         assert img.image_info == sample_image_info
         mockrequests.get.assert_called_with(img.info())
         mockresponse.json.assert_called_with()
 
         # error response
         mockresponse.status_code = 400
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
         img.image_info
         mockresponse.raise_for_status.assert_called_with()
 
     def test_image_width_height(self):
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
 
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=sample_image_info):
             assert img.image_width == sample_image_info['width']
             assert img.image_height == sample_image_info['height']
 
     def test_canonicalize(self):
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
         square_img_info = sample_image_info.copy()
         square_img_info.update({'height': 100, 'width': 100})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=square_img_info):
             # square region for square image = full
             img.region.parse('square')
             # percentage: convert to w,h (= 25,25)
             img.size.parse('pct:25')
             img.rotation.parse('90.0')
-            assert six.text_type(img.canonicalize()) == \
+            assert str(img.canonicalize()) == \
                 '%s/%s/full/25,25/90/default.jpg' % (api_endpoint, image_id)
 
 
 class TestImageRegion:
 
     def test_defaults(self):
-        region = iiif.ImageRegion()
-        assert six.text_type(region) == 'full'
-        assert region.as_dict() == iiif.ImageRegion.region_defaults
+        region = image.ImageRegion()
+        assert str(region) == 'full'
+        assert region.as_dict() == image.ImageRegion.region_defaults
 
     def test_init(self):
         # full
-        region = iiif.ImageRegion(full=True)
+        region = image.ImageRegion(full=True)
         assert region.as_dict()['full'] is True
         # square
-        region = iiif.ImageRegion(square=True)
+        region = image.ImageRegion(square=True)
         assert region.as_dict()['square'] is True
         assert region.as_dict()['full'] is False
         # region
-        region = iiif.ImageRegion(x=5, y=7, width=100, height=103)
+        region = image.ImageRegion(x=5, y=7, width=100, height=103)
         assert region.as_dict()['full'] is False
         assert region.as_dict()['x'] == 5
         assert region.as_dict()['y'] == 7
@@ -296,8 +296,8 @@ def test_init(self):
         assert region.as_dict()['height'] == 103
         assert region.as_dict()['percent'] is False
         # percentage region
-        region = iiif.ImageRegion(x=5, y=7, width=100, height=103,
-                                  percent=True)
+        region = image.ImageRegion(x=5, y=7, width=100, height=103,
+                                   percent=True)
         assert region.as_dict()['full'] is False
         assert region.as_dict()['x'] == 5
         assert region.as_dict()['y'] == 7
@@ -306,54 +306,54 @@ def test_init(self):
         assert region.as_dict()['percent'] is True
 
         # errors
-        with pytest.raises(iiif.IIIFImageClientException):
+        with pytest.raises(image.IIIFImageClientException):
             # invalid parameter
-            iiif.ImageRegion(bogus='foo')
+            image.ImageRegion(bogus='foo')
 
             # incomplete options
-            iiif.ImageRegion(x=1)
-            iiif.ImageRegion(x=1, y=2)
-            iiif.ImageRegion(x=1, y=2, w=20)
-            iiif.ImageRegion(percent=True)
-            iiif.ImageRegion().set_options(percent=True, x=1)
+            image.ImageRegion(x=1)
+            image.ImageRegion(x=1, y=2)
+            image.ImageRegion(x=1, y=2, w=20)
+            image.ImageRegion(percent=True)
+            image.ImageRegion().set_options(percent=True, x=1)
 
             # TODO: type checking? (not yet implemented)
 
-        with pytest.raises(iiif.ParseError):
-            iiif.ImageRegion().parse('1,2')
+        with pytest.raises(image.ParseError):
+            image.ImageRegion().parse('1,2')
 
     def test_render(self):
-        region = iiif.ImageRegion(full=True)
-        assert six.text_type(region) == 'full'
-        region = iiif.ImageRegion(square=True)
-        assert six.text_type(region) == 'square'
-        region = iiif.ImageRegion(x=5, y=5, width=100, height=100)
-        assert six.text_type(region) == '5,5,100,100'
-        region = iiif.ImageRegion(x=5, y=5, width=100, height=100,
-                                  percent=True)
-        assert six.text_type(region) == 'pct:5,5,100,100'
-        region = iiif.ImageRegion(x=5.1, y=3.14, width=100.76, height=100.89,
-                                  percent=True)
-        assert six.text_type(region) == 'pct:5.1,3.14,100.76,100.89'
+        region = image.ImageRegion(full=True)
+        assert str(region) == 'full'
+        region = image.ImageRegion(square=True)
+        assert str(region) == 'square'
+        region = image.ImageRegion(x=5, y=5, width=100, height=100)
+        assert str(region) == '5,5,100,100'
+        region = image.ImageRegion(x=5, y=5, width=100, height=100,
+                                   percent=True)
+        assert str(region) == 'pct:5,5,100,100'
+        region = image.ImageRegion(x=5.1, y=3.14, width=100.76, height=100.89,
+                                   percent=True)
+        assert str(region) == 'pct:5.1,3.14,100.76,100.89'
 
     def test_parse(self):
-        region = iiif.ImageRegion()
+        region = image.ImageRegion()
         # full
         region_str = 'full'
         region.parse(region_str)
-        assert six.text_type(region) == region_str  # round trip
+        assert str(region) == region_str  # round trip
         assert region.as_dict()['full'] is True
         # square
         region_str = 'square'
         region.parse(region_str)
-        assert six.text_type(region) == region_str  # round trip
+        assert str(region) == region_str  # round trip
         assert region.as_dict()['full'] is False
         assert region.as_dict()['square'] is True
         # region
         x, y, w, h = [5, 7, 100, 200]
         region_str = '%d,%d,%d,%d' % (x, y, w, h)
         region.parse(region_str)
-        assert six.text_type(region) == region_str  # round trip
+        assert str(region) == region_str  # round trip
         region_opts = region.as_dict()
         assert region_opts['full'] is False
         assert region_opts['square'] is False
@@ -364,7 +364,7 @@ def test_parse(self):
         # percentage region
         region_str = 'pct:%d,%d,%d,%d' % (x, y, w, h)
         region.parse(region_str)
-        assert six.text_type(region) == region_str  # round trip
+        assert str(region) == region_str  # round trip
         region_opts = region.as_dict()
         assert region_opts['full'] is False
         assert region_opts['square'] is False
@@ -375,139 +375,138 @@ def test_parse(self):
         assert region_opts['percent'] is True
 
         # invalid or incomplete region strings
-        with pytest.raises(iiif.ParseError):
+        with pytest.raises(image.ParseError):
             region.parse('pct:1,3,')
-        with pytest.raises(iiif.ParseError):
+        with pytest.raises(image.ParseError):
             region.parse('one,two,three,four')
 
     def test_canonicalize(self):
         # any canonicalization that requires image dimensions to calculate
         # should raise an error
-        region = iiif.ImageRegion()
+        region = image.ImageRegion()
         region.parse('square')
-        with pytest.raises(iiif.IIIFImageClientException):
+        with pytest.raises(image.IIIFImageClientException):
             region.canonicalize()
 
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
         # full to full - trivial canonicalization
         img.region.canonicalize()
-        assert six.text_type(img.region) == 'full'
+        assert str(img.region) == 'full'
         # x,y,w,h should be preserved as is
         dimensions = '0,0,200,250'
         img.region.parse(dimensions)
         img.region.canonicalize()
         # round trip, should be the same
-        assert six.text_type(img.region) == dimensions
+        assert str(img.region) == dimensions
 
         # test with square image size
         square_img_info = sample_image_info.copy()
         square_img_info.update({'height': 100, 'width': 100})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=square_img_info):
             # square requested, image is square = full
             img.region.parse('square')
             img.region.canonicalize()
-            assert six.text_type(img.region) == 'full'
+            assert str(img.region) == 'full'
 
             # percentages
             img.region.parse('pct:10,1,50,75')
             img.region.canonicalize()
-            assert six.text_type(img.region) == '10,1,50,75'
+            assert str(img.region) == '10,1,50,75'
 
             # percentages should be converted to integers
             img.region.parse('pct:10,1,50.5,75.3')
-            assert six.text_type(img.region) == 'pct:10,1,50.5,75.3'
+            assert str(img.region) == 'pct:10,1,50.5,75.3'
             img.region.canonicalize()
-            assert six.text_type(img.region) == '10,1,50,75'
-
+            assert str(img.region) == '10,1,50,75'
 
         # test with square with non-square image size
         tall_img_info = sample_image_info.copy()
         tall_img_info.update({'width': 100, 'height': 150})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=tall_img_info):
             # square requested, should convert to x,y,w,h
             img.region.parse('square')
             img.region.canonicalize()
-            assert six.text_type(img.region) == '0,25,100,100'
+            assert str(img.region) == '0,25,100,100'
 
         wide_img_info = sample_image_info.copy()
         wide_img_info.update({'width': 200, 'height': 50})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=wide_img_info):
             # square requested, should convert to x,y,w,h
             img.region.parse('square')
             img.region.canonicalize()
 
-            assert six.text_type(img.region) == '75,0,50,50'
+            assert str(img.region) == '75,0,50,50'
 
 
 class TestImageSize:
 
     def test_defaults(self):
-        size = iiif.ImageSize()
-        assert six.text_type(size) == 'full'
-        assert size.as_dict() == iiif.ImageSize.size_defaults
+        size = image.ImageSize()
+        assert str(size) == 'full'
+        assert size.as_dict() == image.ImageSize.size_defaults
 
     def test_init(self):
         # full
-        size = iiif.ImageSize(full=True)
+        size = image.ImageSize(full=True)
         assert size.as_dict()['full'] is True
         # max
-        size = iiif.ImageSize(max=True)
+        size = image.ImageSize(max=True)
         assert size.as_dict()['max'] is True
         assert size.as_dict()['full'] is False
         # percentage
-        size = iiif.ImageSize(percent=50)
+        size = image.ImageSize(percent=50)
         assert size.as_dict()['full'] is False
         assert size.as_dict()['percent'] == 50
         # width only
-        size = iiif.ImageSize(width=100)
+        size = image.ImageSize(width=100)
         assert size.as_dict()['width'] == 100
         # height only
-        size = iiif.ImageSize(height=200)
+        size = image.ImageSize(height=200)
         assert size.as_dict()['height'] == 200
 
         # errors
-        with pytest.raises(iiif.IIIFImageClientException):
+        with pytest.raises(image.IIIFImageClientException):
             # invalid parameter
-            iiif.ImageSize(bogus='foo')
+            image.ImageSize(bogus='foo')
 
             # incomplete options ?
             # type checking? (not yet implemented)
 
     def test_render(self):
-        size = iiif.ImageSize(full=True)
-        assert six.text_type(size) == 'full'
-        size = iiif.ImageSize(max=True)
-        assert six.text_type(size) == 'max'
-        size = iiif.ImageSize(percent=50)
-        assert six.text_type(size) == 'pct:50'
-        size = iiif.ImageSize(width=100, height=105)
-        assert six.text_type(size) == '100,105'
-        size = iiif.ImageSize(width=100)
-        assert six.text_type(size) == '100,'
-        size = iiif.ImageSize(height=105)
-        assert six.text_type(size) == ',105'
+        size = image.ImageSize(full=True)
+        assert str(size) == 'full'
+        size = image.ImageSize(max=True)
+        assert str(size) == 'max'
+        size = image.ImageSize(percent=50)
+        assert str(size) == 'pct:50'
+        size = image.ImageSize(width=100, height=105)
+        assert str(size) == '100,105'
+        size = image.ImageSize(width=100)
+        assert str(size) == '100,'
+        size = image.ImageSize(height=105)
+        assert str(size) == ',105'
 
     def test_parse(self):
-        size = iiif.ImageSize()
+        size = image.ImageSize()
         # full
         size_str = 'full'
         size.parse(size_str)
-        assert six.text_type(size) == size_str  # round trip
+        assert str(size) == size_str  # round trip
         assert size.as_dict()['full'] is True
         # max
         size_str = 'max'
         size.parse(size_str)
-        assert six.text_type(size) == size_str  # round trip
+        assert str(size) == size_str  # round trip
         assert size.as_dict()['full'] is False
         assert size.as_dict()['max'] is True
         # width and height
         w, h = [100, 200]
         size_str = '%d,%d' % (w, h)
         size.parse(size_str)
-        assert six.text_type(size) == size_str  # round trip
+        assert str(size) == size_str  # round trip
         size_opts = size.as_dict()
         assert size_opts['full'] is False
         assert size_opts['max'] is False
@@ -516,102 +515,109 @@ def test_parse(self):
         # percentage size
         size_str = 'pct:55'
         size.parse(size_str)
-        assert six.text_type(size) == size_str  # round trip
+        assert str(size) == size_str  # round trip
         size_opts = size.as_dict()
         assert size_opts['full'] is False
         assert size_opts['percent'] == 55
 
         # invalid or incomplete size strings
-        with pytest.raises(iiif.ParseError):
+        with pytest.raises(image.ParseError):
             size.parse('pct:')
-        with pytest.raises(iiif.ParseError):
+        with pytest.raises(image.ParseError):
             size.parse('one,two')
 
     def test_canonicalize(self):
         # any canonicalization that requires image dimensions to calculate
         # should raise an error
-        size = iiif.ImageSize()
+        size = image.ImageSize()
         size.parse(',5')
-        with pytest.raises(iiif.IIIFImageClientException):
+        with pytest.raises(image.IIIFImageClientException):
             size.canonicalize()
 
-        img = iiif.IIIFImageClient.init_from_url(VALID_URLS['simple'])
+        img = image.IIIFImageClient.init_from_url(VALID_URLS['simple'])
         # full to full - trivial canonicalization
         img.size.canonicalize()
-        assert six.text_type(img.size) == 'full'
+        assert str(img.size) == 'full'
 
         # test sizes with square image size
         square_img_info = sample_image_info.copy()
         square_img_info.update({'height': 100, 'width': 100})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=square_img_info):
             # requested as ,h - convert to w,
             img.size.parse(',50')
             img.size.canonicalize()
-            assert six.text_type(img.size) == '50,'
+            assert str(img.size) == '50,'
 
             # percentage: convert to w,h
             img.size.parse('pct:25')
             img.size.canonicalize()
-            assert six.text_type(img.size) == '25,25'
+            assert str(img.size) == '25,25'
 
             # exact
             img.size.parse('!50,50')
             img.size.canonicalize()
-            assert six.text_type(img.size) == '50,50'
+            assert str(img.size) == '50,50'
 
         # test sizes with rectangular image size
         rect_img_info = sample_image_info.copy()
         rect_img_info.update({'width': 50, 'height': 100})
-        with patch.object(iiif.IIIFImageClient, 'image_info',
+        with patch.object(image.IIIFImageClient, 'image_info',
                           new=rect_img_info):
             img.size.parse('!50,50')
             img.size.canonicalize()
-            assert six.text_type(img.size) == '25,50'
+            assert str(img.size) == '25,50'
 
 
 class TestImageRotation:
 
     def test_defaults(self):
-        rotation = iiif.ImageRotation()
-        assert six.text_type(rotation) == '0'
-        assert rotation.as_dict() == iiif.ImageRotation.rotation_defaults
+        rotation = image.ImageRotation()
+        assert str(rotation) == '0'
+        assert rotation.as_dict() == image.ImageRotation.rotation_defaults
 
     def test_init(self):
         # degrees
-        rotation = iiif.ImageRotation(degrees=90)
+        rotation = image.ImageRotation(degrees=90)
         assert rotation.as_dict()['degrees'] == 90
         assert rotation.as_dict()['mirrored'] is False
-        rotation = iiif.ImageRotation(degrees=95, mirrored=True)
+        rotation = image.ImageRotation(degrees=95, mirrored=True)
         assert rotation.as_dict()['degrees'] == 95
         assert rotation.as_dict()['mirrored'] is True
 
     def test_render(self):
-        rotation = iiif.ImageRotation()
-        assert six.text_type(rotation) == '0'
-        rotation = iiif.ImageRotation(degrees=90)
-        assert six.text_type(rotation) == '90'
-        rotation = iiif.ImageRotation(degrees=95, mirrored=True)
-        assert six.text_type(rotation) == '!95'
+        rotation = image.ImageRotation()
+        assert str(rotation) == '0'
+        rotation = image.ImageRotation(degrees=90)
+        assert str(rotation) == '90'
+        rotation = image.ImageRotation(degrees=95, mirrored=True)
+        assert str(rotation) == '!95'
 
         # canonicalization
         # - trim any trailing zeros in a decimal value
-        assert six.text_type(iiif.ImageRotation(degrees=93.0)) == '93'
+        assert str(image.ImageRotation(degrees=93.0)) == '93'
         # - leading zero if less than 1
-        assert six.text_type(iiif.ImageRotation(degrees=0.05)) == '0.05'
+        assert str(image.ImageRotation(degrees=0.05)) == '0.05'
         # - ! if mirrored, followed by integer if possible
-        rotation = iiif.ImageRotation(degrees=95.00, mirrored=True)
-        assert six.text_type(rotation) == '!95'
+        rotation = image.ImageRotation(degrees=95.00, mirrored=True)
+        assert str(rotation) == '!95'
         # explicitly test canonicalize method, even though it does nothing
         rotation.canonicalize()
-        assert six.text_type(rotation) == '!95'
+        assert str(rotation) == '!95'
 
     def test_parse(self):
-        rotation = iiif.ImageRotation()
+        rotation = image.ImageRotation()
         rotation_str = '180'
         rotation.parse(rotation_str)
-        assert six.text_type(rotation) == rotation_str  # round trip
+        assert str(rotation) == rotation_str  # round trip
         rotation_str = '!90'
         rotation.parse(rotation_str)
-        assert six.text_type(rotation) == rotation_str  # round trip
+        assert str(rotation) == rotation_str  # round trip
         assert rotation.as_dict()['mirrored'] is True
+
+
+@pytest.mark.skip
+def test_deprecated():
+    with pytest.raises(DeprecationWarning) as warn:
+        from piffle import iiif
+    assert 'piffle.iiif is deprecated' in str(warn)
diff --git a/tests/test_presentation.py b/tests/test_presentation.py
new file mode 100644
index 0000000..c71f1b5
--- /dev/null
+++ b/tests/test_presentation.py
@@ -0,0 +1,122 @@
+import json
+import os.path
+from unittest.mock import patch
+
+import pytest
+import requests
+
+from piffle.presentation import IIIFPresentation, IIIFException
+
+
+FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
+
+
+class TestIIIFPresentation:
+    test_manifest = os.path.join(FIXTURE_DIR, 'chto-manifest.json')
+
+    def test_from_file(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        assert isinstance(pres, IIIFPresentation)
+        assert pres.type == 'sc:Manifest'
+
+    def test_from_url(self):
+        manifest_url = 'http://ma.ni/fe.st'
+        with open(self.test_manifest) as manifest:
+            data = json.loads(manifest.read())
+        with patch('piffle.presentation.requests') as mockrequests:
+            mockrequests.codes = requests.codes
+            mockresponse = mockrequests.get.return_value
+            mockresponse.status_code = requests.codes.ok
+            mockresponse.json.return_value = data
+            pres = IIIFPresentation.from_url(manifest_url)
+            assert pres.type == 'sc:Manifest'
+            mockrequests.get.assert_called_with(manifest_url)
+            mockrequests.get.return_value.json.assert_called_with()
+
+            # error handling
+            # bad status code response on the url
+            with pytest.raises(IIIFException) as excinfo:
+                mockresponse.status_code = requests.codes.forbidden
+                mockresponse.reason = 'Forbidden'
+                IIIFPresentation.from_url(manifest_url)
+            assert 'Error retrieving manifest' in str(excinfo.value)
+            assert '403 Forbidden' in str(excinfo.value)
+
+            # valid http response but not a json response
+            with pytest.raises(IIIFException) as excinfo:
+                mockresponse.status_code = requests.codes.ok
+                # content type header does not indicate json
+                mockresponse.headers = {'content-type': 'text/html'}
+                mockresponse.json.side_effect = \
+                    json.decoder.JSONDecodeError('err', 'doc', 1)
+                IIIFPresentation.from_url(manifest_url)
+            assert 'No JSON found' in str(excinfo.value)
+
+            # json parsing error
+            with pytest.raises(IIIFException) as excinfo:
+                # content type header indicates json, but parsing failed
+                mockresponse.headers = {'content-type': 'application/json'}
+                mockresponse.json.side_effect = \
+                    json.decoder.JSONDecodeError('err', 'doc', 1)
+                IIIFPresentation.from_url(manifest_url)
+            assert 'Error parsing JSON' in str(excinfo.value)
+
+    def test_from_url_or_file(self):
+        with patch.object(IIIFPresentation, 'from_url') as mock_from_url:
+            # local fixture file
+            pres = IIIFPresentation.from_file_or_url(self.test_manifest)
+            assert pres.type == 'sc:Manifest'
+            mock_from_url.assert_not_called()
+
+            pres = IIIFPresentation.from_file_or_url('http://mani.fe/st')
+            mock_from_url.assert_called_with('http://mani.fe/st')
+
+            # nonexistent file path
+            with pytest.raises(IIIFException) as excinfo:
+                IIIFPresentation.from_file_or_url('/manifest/not/found')
+            assert 'File not found: ' in str(excinfo.value)
+
+    def test_short_id(self):
+        manifest_uri = 'https://ii.if/resources/p0c484h74c/manifest'
+        assert IIIFPresentation.short_id(manifest_uri) == 'p0c484h74c'
+        canvas_uri = 'https://ii.if/resources/p0c484h74c/manifest/canvas/ps7527b878'
+        assert IIIFPresentation.short_id(canvas_uri) == 'ps7527b878'
+
+    def test_toplevel_attrs(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        assert pres.context == "http://iiif.io/api/presentation/2/context.json"
+        assert pres.id == "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest"
+        assert pres.type == "sc:Manifest"
+        assert pres.label[0] == "Chto my stroim : Tetrad\u02b9 s kartinkami"
+        assert pres.viewingHint == "paged"
+        assert pres.viewingDirection == "left-to-right"
+
+    def test_nested_attrs(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        assert isinstance(pres.sequences, tuple)
+        assert pres.sequences[0].id == \
+            "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/sequence/normal"
+        assert pres.sequences[0].type == "sc:Sequence"
+        assert isinstance(pres.sequences[0].canvases, tuple)
+        assert pres.sequences[0].canvases[0].id == \
+            "https://plum.princeton.edu/concern/scanned_resources/ph415q7581/manifest/canvas/p02871v98d"
+
+    def test_set(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        pres.label = 'New title'
+        pres.type = 'sc:Collection'
+        assert pres.label == 'New title'
+        assert pres.type == 'sc:Collection'
+
+    def test_del(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        del pres.label
+        del pres.type
+        assert not hasattr(pres, 'label')
+        assert not hasattr(pres, 'type')
+
+    def test_first_label(self):
+        pres = IIIFPresentation.from_file(self.test_manifest)
+        assert pres.first_label == pres.label[0]
+        pres.label = 'unlisted single title'
+        assert pres.first_label == pres.label