diff --git a/.flake8 b/.flake8 deleted file mode 100644 index f51e54c3..00000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 -# also ignore, -# - E741 ambiguous variable name -[flake8] -max-line-length = 88 -extend-ignore = E203,E741 diff --git a/.github/ISSUE_TEMPLATE/python-bump.md b/.github/ISSUE_TEMPLATE/python-bump.md index ab02f85e..8da37b22 100644 --- a/.github/ISSUE_TEMPLATE/python-bump.md +++ b/.github/ISSUE_TEMPLATE/python-bump.md @@ -1,20 +1,11 @@ -# Adding wheels for a new Python release +# Adding a new Python release - Update Git main - [ ] `git checkout main` - - [ ] Add classifier for new Python version to `setup.py` - - [ ] Add new Python version to lists in `.github/workflows/python.yml` + - [ ] In `pyproject.toml`, add classifier for new Python version and update `tool.black.target-version` + - [ ] In `.github/workflows/python.yml`, update hardcoded Python versions and add new version to lists - [ ] Commit and open a PR - [ ] Merge the PR when CI passes - [ ] Add new Python jobs to [branch protection required checks](https://github.com/openslide/openslide-python/settings/branches) -- Build new wheels - - [ ] Check out a new branch from the most recent release tag - - [ ] Add new Python version to lists in `.github/workflows/python.yml`, commit, and open a DNM PR - - [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions) for the PR; download its wheels artifact - - [ ] Close the PR -- [ ] In OpenSlide Python checkout, `git checkout v && git clean -dxf && mkdir dist` -- [ ] Copy downloaded wheels _from new Python release only_ into `dist` directory -- [ ] `twine upload dist/*` -- [ ] Upload new wheels to [GitHub release](https://github.com/openslide/openslide-python/releases) - [ ] Update MacPorts package - [ ] Update website: Python 3 versions in `download/index.md` diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 72b6332c..c4d29a15 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -2,15 +2,17 @@ - [ ] Update `CHANGELOG.md` and version in `openslide/_version.py` - [ ] Create and push signed tag -- [ ] `git clean -dxf && mkdir dist` -- [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions) for the tag; download its docs and wheels artifacts -- [ ] `unzip /path/to/downloaded/openslide-python-wheels.zip && mv openslide-python-wheels-*/* dist/` -- [ ] `python setup.py sdist` -- [ ] `twine upload dist/*` -- [ ] Recompress tarball with `xz` -- [ ] Attach release notes to [GitHub release](https://github.com/openslide/openslide-python/releases/new); upload tarballs and wheels +- [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions/workflows/python.yml) for the tag + - [ ] Once the build finishes, approve deployment to PyPI + - [ ] Download the docs artifact +- [ ] Verify that the workflow created a [PyPI release](https://pypi.org/p/openslide-python) with a description, source tarball, and wheels +- [ ] Verify that the workflow created a [GitHub release](https://github.com/openslide/openslide-python/releases) with release notes, source tarballs, and wheels - [ ] `cd` into website checkout; `rm -r api/python && unzip /path/to/downloaded/openslide-python-docs.zip && mv openslide-python-docs-* api/python` - [ ] Update website: `_data/releases.yaml`, `_includes/news.md` +- [ ] Start a [CI build](https://github.com/openslide/openslide.github.io/actions/workflows/retile.yml) of the demo site +- [ ] Update Ubuntu PPA +- [ ] Update Fedora and possibly EPEL packages +- [ ] Check that [Copr package](https://copr.fedorainfracloud.org/coprs/g/openslide/openslide/builds/) built successfully - [ ] Send mail to -announce and -users -- [ ] Update Fedora package +- [ ] Post to [forum.image.sc](https://forum.image.sc/c/announcements/10) - [ ] Update MacPorts package diff --git a/.github/workflows/dco-report.yml b/.github/workflows/dco-report.yml index c4e728f9..df3e8da4 100644 --- a/.github/workflows/dco-report.yml +++ b/.github/workflows/dco-report.yml @@ -7,9 +7,11 @@ on: - completed permissions: - pull-requests: write + contents: none jobs: comment: name: Organization uses: openslide/.github/.github/workflows/dco-report.yml@main + secrets: + OPENSLIDE_BOT_TOKEN: ${{ secrets.OPENSLIDE_BOT_TOKEN }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c0ca8656..64887e34 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -17,95 +17,190 @@ jobs: pre-commit: name: Rerun pre-commit checks runs-on: ubuntu-latest + outputs: + dist-base: ${{ steps.paths.outputs.dist }} + docs-base: ${{ steps.paths.outputs.docs }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - name: Check out repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: '3.11' - - uses: pre-commit/action@v3.0.0 + python-version: '3.13' + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 + - name: Define artifact paths + id: paths + run: | + suffix="$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10)" + echo "dist=openslide-python-dist-$suffix" >> $GITHUB_OUTPUT + echo "docs=openslide-python-docs-$suffix" >> $GITHUB_OUTPUT + tests: name: Tests needs: pre-commit runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] - python-version: [3.8, 3.9, "3.10", "3.11"] + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14-dev"] + openslide: [system, wheel] + include: + - os: ubuntu-latest + python-version: "3.13" + openslide: system + sdist: sdist steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + if: matrix.upstream-python == null + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} (macOS fallback) + if: matrix.upstream-python != null + run: | + pkgdir="${{ runner.temp }}/python" + mkdir -p "$pkgdir/bin" + pkg="$pkgdir/python.pkg" + curl -Lfo "$pkg" \ + "https://www.python.org/ftp/python/${{ matrix.upstream-python }}/python-${{ matrix.upstream-python }}-macos11.pkg" + sudo installer -pkg "$pkg" -target / + for bin in python pip; do + ln -s /usr/local/bin/${bin}3 $pkgdir/bin/${bin} + done + export PATH="$pkgdir/bin:$PATH" + echo "PATH=$PATH" >> $GITHUB_ENV + python -V + pip -V - name: Install Python tools run: | python -m pip install --upgrade pip - pip install jinja2 pytest - - name: Install OpenSlide + pip install auditwheel build jinja2 pytest + - name: Install OpenSlide (system) + if: matrix.openslide == 'system' run: | case "${{ matrix.os }}" in ubuntu-latest) + echo OS_ARCH_TAG=linux-x86_64 >> $GITHUB_ENV + sudo apt-get install libopenslide0 + ;; + ubuntu-24.04-arm) + echo OS_ARCH_TAG=linux-aarch64 >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; macos-latest) + echo OS_ARCH_TAG=macos-arm64-x86_64 >> $GITHUB_ENV + echo DYLD_LIBRARY_PATH=/opt/homebrew/lib >> $GITHUB_ENV brew install openslide ;; esac + - name: Install OpenSlide (wheel) + if: matrix.openslide == 'wheel' + run: pip install openslide-bin + - name: Build dist + run: | + if [ -z "${{ matrix.sdist }}" ]; then + wheel_only=-w + fi + python -m build $wheel_only + case "${{ matrix.os }}" in + ubuntu-*) + mkdir old + mv dist/*.whl old/ + auditwheel repair --only-plat -w dist old/*whl + ;; + macos-*) + if [ ! -e dist/*universal2* ]; then + echo "Wheel is not universal:" + ls dist + exit 1 + fi + esac + if [ -z "$wheel_only" ]; then + mkdir -p "artifacts/src/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/*.tar.gz "artifacts/src/${{ needs.pre-commit.outputs.dist-base }}" + fi + mkdir -p "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/* "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" + # from system builds, save version-specific wheels and oldest abi3 wheel + python -c 'import sys + if sys.version_info < (3, 12) and "${{ matrix.openslide }}" == "system": + print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install . + run: pip install artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests run: pytest -v - name: Tile slide run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs + - name: Archive sdist + if: matrix.sdist + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.pre-commit.outputs.dist-base }}-source + path: artifacts/src + compression-level: 0 + - name: Archive wheel + if: env.archive_wheel + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.pre-commit.outputs.dist-base }}-${{ env.OS_ARCH_TAG }}-${{ matrix.python-version }} + path: artifacts/whl + compression-level: 0 + windows: name: Windows needs: pre-commit runs-on: windows-latest - env: - WINBUILD_RELEASE: 20230414 defaults: run: shell: bash strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] - python-arch: [x86, x64] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14-dev"] + openslide: [zip, wheel] steps: - name: Check out repo - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} ${{ matrix.python-arch }} - uses: actions/setup-python@v4 + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.python-arch }} - name: Install Python tools run: | python -m pip install --upgrade pip - pip install flask pytest wheel - # Current Pillow releases don't have 32-bit wheels - # https://github.com/python-pillow/Pillow/issues/7251 - pip install Pillow --only-binary=:all: - - name: Install OpenSlide + pip install build flask pytest + - name: Install OpenSlide (zip) + if: matrix.openslide == 'zip' + env: + GH_TOKEN: ${{ github.token }} run: | - case "${{ matrix.python-arch }}" in - x86) zipname=openslide-win32-${WINBUILD_RELEASE} ;; - x64) zipname=openslide-win64-${WINBUILD_RELEASE} ;; - esac mkdir -p c:\\openslide cd c:\\openslide - curl -LO "https://github.com/openslide/openslide-winbuild/releases/download/v${WINBUILD_RELEASE}/${zipname}.zip" + release=$(gh release list -R openslide/openslide-bin -L 1 \ + --json tagName --exclude-drafts --exclude-pre-releases | \ + jq -r .[0].tagName | \ + tr -d v) + zipname="openslide-bin-${release}-windows-x64" + gh release download -R openslide/openslide-bin "v${release}" \ + --pattern "${zipname}.zip" 7z x ${zipname}.zip echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV + - name: Install OpenSlide (wheel) + if: matrix.openslide == 'wheel' + run: pip install openslide-bin - name: Build wheel run: | - python setup.py bdist_wheel - basename=openslide-python-wheels-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) - mkdir -p "artifacts/${basename}" - mv dist/*.whl "artifacts/${basename}" - echo "basename=${basename}" >> $GITHUB_ENV + python -m build -w + mkdir -p "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/*.whl "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" + # from zip builds, save version-specific wheels and oldest abi3 wheel + python -c 'import sys + if sys.version_info < (3, 12) and "${{ matrix.openslide }}" == "zip": + print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install -e . + run: pip install artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests # Reads OPENSLIDE_PATH run: pytest -v @@ -119,32 +214,99 @@ jobs: # Reads OPENSLIDE_PATH run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs - name: Archive wheel - uses: actions/upload-artifact@v3 + if: env.archive_wheel + uses: actions/upload-artifact@v4 with: - name: ${{ env.basename }} - path: artifacts + name: ${{ needs.pre-commit.outputs.dist-base }}-windows-x64-${{ matrix.python-version }} + path: artifacts/whl + compression-level: 0 + + setuptools: + name: Setuptools install + needs: pre-commit + runs-on: ubuntu-latest + container: ubuntu:22.04 + steps: + - name: Install dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + git libopenslide0 python3-jinja2 python3-pil python3-pip + pip install pytest + - name: Check out repo + uses: actions/checkout@v4 + - name: Install OpenSlide Python + run: python3 setup.py install + - name: Run tests + run: pytest -v + - name: Tile slide + run: python3 examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs + docs: name: Docs needs: pre-commit runs-on: ubuntu-latest steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' - name: Install Python tools run: | python -m pip install --upgrade pip pip install sphinx - name: Build - run: | - basename=openslide-python-docs-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) - sphinx-build -d doctrees doc artifact/${basename} - echo "basename=${basename}" >> $GITHUB_ENV + run: sphinx-build -d doctrees doc artifact/${{ needs.pre-commit.outputs.docs-base }} - name: Archive - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: ${{ env.basename }} + name: ${{ needs.pre-commit.outputs.docs-base }} path: artifact + + release: + name: Release + if: github.ref_type == 'tag' + environment: + name: pypi + url: https://pypi.org/p/openslide-python + needs: [pre-commit, tests, windows] + runs-on: ubuntu-latest + concurrency: release-${{ github.ref }} + permissions: + contents: write + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: "${{ needs.pre-commit.outputs.dist-base }}-*" + merge-multiple: true + - name: Release to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ needs.pre-commit.outputs.dist-base }} + repository-url: ${{ vars.PYPI_URL }} + - name: Release to GitHub + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + version=$(echo "${{ github.ref_name }}" | sed "s/^v//") + # recompress tarball with xz + gunzip -k "${{ needs.pre-commit.outputs.dist-base }}/openslide_python-${version}.tar.gz" + tar xf "${{ needs.pre-commit.outputs.dist-base }}/openslide_python-${version}.tar" + xz -9 "${{ needs.pre-commit.outputs.dist-base }}/openslide_python-${version}.tar" + # extract changelog + awk -e '/^## / && ok {exit}' \ + -e '/^## / {ok=1; next}' \ + -e 'ok {print}' \ + "openslide_python-$version/CHANGELOG.md" > changes + # create release; upload artifacts but not *.publish.attestation + # files created by gh-action-pypi-publish + gh release create --latest --verify-tag \ + --repo "${{ github.repository }}" \ + --title "OpenSlide Python $version" \ + --notes-file changes \ + "${{ github.ref_name }}" \ + "${{ needs.pre-commit.outputs.dist-base }}/"*.{tar.gz,tar.xz,whl} diff --git a/.gitignore b/.gitignore index 87d0e176..d3dc441a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /build /dist -/MANIFEST /*.egg-info *.pyc diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 716bf5fc..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -profile = black -force_sort_within_sections = true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b63ae05..e01c82ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,63 +3,94 @@ exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict + - id: check-toml + - id: check-vcs-permalinks - id: check-yaml - id: end-of-file-fixer - exclude: '^\.github/.*\.md$' + - id: fix-byte-order-marker + - id: mixed-line-ending - id: trailing-whitespace - exclude: '^\.github/.*\.md$' - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v3.19.1 hooks: - id: pyupgrade name: Modernize python code - args: ["--py37-plus"] + args: ["--py39-plus"] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 6.0.1 hooks: - id: isort name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 25.1.0 hooks: - id: black name: Format python code with black - language_version: python3 - args: ["--skip-string-normalization"] - repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 + rev: 1.19.1 hooks: - id: blacken-docs name: Format python code in documentation - repo: https://github.com/asottile/yesqa - rev: v1.3.0 + rev: v1.5.0 hooks: - id: yesqa + additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 7.2.0 hooks: - id: flake8 name: Lint python code with flake8 - additional_dependencies: [flake8-bugbear] + additional_dependencies: [flake8-bugbear, Flake8-pyproject] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + name: Check Python types + additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] - repo: https://github.com/rstcheck/rstcheck - rev: v6.0.0.post1 + rev: v6.2.4 hooks: - id: rstcheck name: Validate reStructuredText syntax - additional_dependencies: [sphinx] + additional_dependencies: [sphinx, toml] + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + name: Check spelling with codespell + additional_dependencies: + - tomli # Python < 3.11 - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes + + - repo: local + hooks: + - id: annotations + name: Require "from __future__ import annotations" + language: pygrep + types: [python] + # exclude config-like files + exclude: "^(setup\\.py|doc/conf\\.py|openslide/_version\\.py)$" + # Allow files with import statement, or of less than two characters. + # One-character files are allowed because that's the best we can do + # with paired negative lookbehind and lookahead assertions. ^ and $ + # don't work because --multiline causes them to match at newlines. + entry: "(?29 pixels per call `--without-performance` +* Fix reading ≥ 229 pixels per call `--without-performance` * Fix some `unclosed file` ResourceWarnings on Python 3 * Improve object reprs * Add test suite -* examples: Drop support for Internet Explorer < 9 +* examples: Drop support for Internet Explorer \< 9 + ## Version 1.1.0, 2015-04-20 @@ -57,21 +125,25 @@ * examples: Verify at server startup that file was specified * examples: Disable pinch zoom outside of viewer + ## Version 1.0.1, 2014-03-09 * Fix documentation build breakage + ## Version 1.0.0, 2014-03-09 * Add documentation * Switch from distutils to setuptools * Declare Pillow dependency in `setup.py` (but still support PIL) + ## Version 0.5.1, 2014-01-26 * Fix breakage on Python 2.6 * examples: Fix tile server breakage on classic PIL + ## Version 0.5.0, 2014-01-25 * Require OpenSlide 3.4.0 @@ -87,6 +159,7 @@ * examples: Avoid loading smallest Deep Zoom levels * examples: Update OpenSeadragon to 1.0.0 + ## Version 0.4.0, 2012-09-08 * Require OpenSlide 3.3.0 @@ -95,6 +168,7 @@ * Properly report `openslide_open()` errors on OpenSlide 3.3.0 * Fix library loading on Mac OS X + ## Version 0.3.0, 2011-12-16 * Fix segfault if properties/associated images accessed after `OpenSlide` @@ -104,6 +178,7 @@ * Fix for large JPEG tiles in example Deep Zoom tilers * Make example static tiler output self-contained + ## Version 0.2.0, 2011-09-02 * Initial library release diff --git a/COPYING.LESSER b/COPYING.LESSER index 4362b491..f6683e74 100644 --- a/COPYING.LESSER +++ b/COPYING.LESSER @@ -2,7 +2,7 @@ Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. @@ -484,8 +484,7 @@ convey the exclusion of warranty; and each file should have at least the Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + License along with this library; if not, see . Also add information on how to contact you by electronic and paper mail. @@ -496,7 +495,7 @@ necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. - , 1 April 1990 - Ty Coon, President of Vice + , 1 April 1990 + Moe Ghoul, President of Vice That's all there is to it! diff --git a/MANIFEST.in b/MANIFEST.in index b84f5159..ea26c3f7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ -include *.md pytest.ini +include *.md +global-include LICENSE.* py.typed *.pyi recursive-include doc *.py *.rst recursive-include examples *.html *.js *.png *.py recursive-include tests *.dcm *.png *.py *.svs *.tiff diff --git a/README.md b/README.md index 26d8bf79..50f426d9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ OpenSlide can read virtual slides in several formats: * [Sakura][] (`.svslide`) * [Trestle][] (`.tif`) * [Ventana][] (`.bif`, `.tif`) +* [Zeiss][] (`.czi`) * [Generic tiled TIFF][] (`.tif`) [OpenSlide]: https://openslide.org/ @@ -34,21 +35,28 @@ OpenSlide can read virtual slides in several formats: [Sakura]: https://openslide.org/formats/sakura/ [Trestle]: https://openslide.org/formats/trestle/ [Ventana]: https://openslide.org/formats/ventana/ +[Zeiss]: https://openslide.org/formats/zeiss/ [Generic tiled TIFF]: https://openslide.org/formats/generic-tiff/ ## Requirements -* Python ≥ 3.8 -* OpenSlide ≥ 3.4.0 +* Python ≥ 3.9 +* OpenSlide ≥ 3.4.0 * Pillow ## Installation -OpenSlide Python requires [OpenSlide]. For instructions on installing both -components so OpenSlide Python can find OpenSlide, see the package -[documentation][installing]. +OpenSlide Python requires [OpenSlide]. Install both components from PyPI +with: + +```console +pip install openslide-python openslide-bin +``` + +Or, see the [OpenSlide Python documentation][installing] for instructions on +installing so OpenSlide Python can find OpenSlide. [installing]: https://openslide.org/api/python/#installing @@ -65,7 +73,13 @@ components so OpenSlide Python can find OpenSlide, see the package ## License OpenSlide Python is released under the terms of the [GNU Lesser General -Public License, version 2.1](https://openslide.org/license/). +Public License, version 2.1](https://openslide.org/license/). The Deep Zoom +example code includes JavaScript released under the +[BSD license](https://github.com/openslide/openslide-python/tree/main/examples/deepzoom/licenses/LICENSE.openseadragon), +the +[MIT license](https://github.com/openslide/openslide-python/tree/main/examples/deepzoom/licenses/LICENSE.jquery), +and released into the +[public domain](https://github.com/openslide/openslide-python/tree/main/examples/deepzoom/licenses/LICENSE.openseadragon-scalebar). OpenSlide Python is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY diff --git a/doc/conf.py b/doc/conf.py index 27ce2da2..b6d694c5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -22,7 +22,7 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +needs_sphinx = '1.6' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -46,7 +46,7 @@ # General information about the project. project = 'OpenSlide Python' -copyright = '2010-2023 Carnegie Mellon University and others' +copyright = '2010-2024 Carnegie Mellon University and others' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -174,98 +174,6 @@ # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None -# Output file base name for HTML help builder. -htmlhelp_basename = 'OpenSlidePythondoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # 'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ( - 'index', - 'OpenSlidePython.tex', - 'OpenSlide Python Documentation', - 'OpenSlide project', - 'manual', - ), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - 'index', - 'openslidepython', - 'OpenSlide Python Documentation', - ['OpenSlide project'], - 1, - ) -] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - 'index', - 'OpenSlidePython', - 'OpenSlide Python Documentation', - 'OpenSlide project', - 'OpenSlidePython', - 'One line description of project.', - 'Miscellaneous', - ), -] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - # intersphinx intersphinx_mapping = { diff --git a/doc/index.rst b/doc/index.rst index 68cf41fc..9efdc64b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -24,6 +24,7 @@ OpenSlide can read virtual slides in several formats: * Sakura_ (``.svslide``) * Trestle_ (``.tif``) * Ventana_ (``.bif``, ``.tif``) +* Zeiss_ (``.czi``) * `Generic tiled TIFF`_ (``.tif``) OpenSlide Python is released under the terms of the `GNU Lesser General @@ -39,6 +40,7 @@ Public License, version 2.1`_. .. _Sakura: https://openslide.org/formats/sakura/ .. _Trestle: https://openslide.org/formats/trestle/ .. _Ventana: https://openslide.org/formats/ventana/ +.. _Zeiss: https://openslide.org/formats/zeiss/ .. _`Generic tiled TIFF`: https://openslide.org/formats/generic-tiff/ .. _`GNU Lesser General Public License, version 2.1`: https://openslide.org/license/ @@ -47,17 +49,20 @@ Installing ========== OpenSlide Python requires OpenSlide_, which must be installed separately. +If you intend to use OpenSlide only with Python, the easiest way to get it +is to install the openslide-bin_ Python package with +``pip install openslide-bin``. -On Linux and macOS, the easiest way to get both components is to install_ -with a package manager that packages both, such as Anaconda_, DNF or Apt on -Linux systems, or MacPorts_ on macOS systems. You can also install +On Linux and macOS, you can also install_ both OpenSlide and OpenSlide +Python with a package manager that packages both, such as Anaconda_, DNF or +Apt on Linux systems, or MacPorts_ on macOS systems. Or, you can install OpenSlide Python with pip_ after installing OpenSlide with a package manager or from source_. Except for pip, do not mix OpenSlide and OpenSlide Python from different package managers (for example, OpenSlide from MacPorts and OpenSlide Python from Anaconda), since you'll get library conflicts. -On Windows, download the OpenSlide `Windows binaries`_ and extract them -to a known path. Then, import ``openslide`` inside a +On Windows, you can also download the OpenSlide `Windows binaries`_ and +extract them to a known path. Then, import ``openslide`` inside a ``with os.add_dll_directory()`` statement:: # The path can also be read from a config file, etc. @@ -71,12 +76,13 @@ to a known path. Then, import ``openslide`` inside a else: import openslide +.. _openslide-bin: https://pypi.org/project/openslide-bin/ .. _install: https://openslide.org/download/#distribution-packages .. _Anaconda: https://anaconda.org/ .. _MacPorts: https://www.macports.org/ .. _pip: https://pip.pypa.io/en/stable/ .. _source: https://openslide.org/download/#source -.. _`Windows binaries`: https://openslide.org/download/#windows-binaries +.. _`Windows binaries`: https://openslide.org/download/#binaries Basic usage @@ -87,25 +93,25 @@ OpenSlide objects .. module:: openslide -.. class:: OpenSlide(filename) +.. class:: OpenSlide(filename: str | bytes | ~os.PathLike[typing.Any]) An open whole-slide image. If any operation on the object fails, :exc:`OpenSlideError` is raised. OpenSlide has latching error semantics: once :exc:`OpenSlideError` is raised, all future operations on the :class:`OpenSlide`, other than - :meth:`close()`, will also raise :exc:`OpenSlideError`. + :meth:`close`, will also raise :exc:`OpenSlideError`. - :meth:`close()` is called automatically when the object is deleted. + :meth:`close` is called automatically when the object is deleted. The object may be used as a context manager, in which case it will be closed upon exiting the context. - :param str filename: the file to open + :param filename: the file to open :raises OpenSlideUnsupportedFormatError: if the file is not recognized by OpenSlide :raises OpenSlideError: if the file is recognized but an error occurred - .. classmethod:: detect_format(filename) + .. classmethod:: detect_format(filename: str | bytes | ~os.PathLike[typing.Any]) -> str | None Return a string describing the format vendor of the specified file. This string is also accessible via the :data:`PROPERTY_NAME_VENDOR` @@ -113,85 +119,97 @@ OpenSlide objects If the file is not recognized, return :obj:`None`. - :param str filename: the file to examine + :param filename: the file to examine .. attribute:: level_count The number of levels in the slide. Levels are numbered from ``0`` (highest resolution) to ``level_count - 1`` (lowest resolution). + :type: int + .. attribute:: dimensions A ``(width, height)`` tuple for level 0 of the slide. + :type: tuple[int, int] + .. attribute:: level_dimensions - A list of ``(width, height)`` tuples, one for each level of the slide. + A tuple of ``(width, height)`` tuples, one for each level of the slide. ``level_dimensions[k]`` are the dimensions of level ``k``. + :type: tuple[tuple[int, int], ...] + .. attribute:: level_downsamples - A list of downsample factors for each level of the slide. + A tuple of downsample factors for each level of the slide. ``level_downsamples[k]`` is the downsample factor of level ``k``. + :type: tuple[float, ...] + .. attribute:: properties Metadata about the slide, in the form of a :class:`~collections.abc.Mapping` from OpenSlide property name to - property value. Property values are always strings. OpenSlide - provides some :ref:`standard-properties`, plus - additional properties that vary by slide format. + property value. OpenSlide provides some :ref:`standard-properties`, + plus additional properties that vary by slide format. + + :type: ~collections.abc.Mapping[str, str] .. attribute:: associated_images Images, such as label or macro images, which are associated with this slide. This is a :class:`~collections.abc.Mapping` from image - name to RGBA :class:`Image `. + name to RGBA :class:`~PIL.Image.Image`. Unlike in the C interface, these images are not premultiplied. + :type: ~collections.abc.Mapping[str, ~PIL.Image.Image] + .. attribute:: color_profile The embedded :ref:`color profile ` for this slide, - as a Pillow :class:`~PIL.ImageCms.ImageCmsProfile`, or :obj:`None` if - not available. + or :obj:`None` if not available. + + :type: ~PIL.ImageCms.ImageCmsProfile | None - .. method:: read_region(location, level, size) + .. method:: read_region(location: tuple[int, int], level: int, size: tuple[int, int]) -> ~PIL.Image.Image - Return an RGBA :class:`Image ` containing the - contents of the specified region. + Return an RGBA :class:`~PIL.Image.Image` containing the contents of + the specified region. Unlike in the C interface, the image data is not premultiplied. - :param tuple location: ``(x, y)`` tuple giving the top left pixel in - the level 0 reference frame - :param int level: the level number - :param tuple size: ``(width, height)`` tuple giving the region size + :param location: ``(x, y)`` tuple giving the top left pixel in the + level 0 reference frame + :param level: the level number + :param size: ``(width, height)`` tuple giving the region size - .. method:: get_best_level_for_downsample(downsample) + .. method:: get_best_level_for_downsample(downsample: float) -> int Return the best level for displaying the given downsample. - :param float downsample: the desired downsample factor + :param downsample: the desired downsample factor - .. method:: get_thumbnail(size) + .. method:: get_thumbnail(size: tuple[int, int]) -> ~PIL.Image.Image - Return an :class:`Image ` containing an RGB thumbnail - of the slide. + Return an :class:`~PIL.Image.Image` containing an RGB thumbnail of the + slide. - :param tuple size: the maximum size of the thumbnail as a - ``(width, height)`` tuple + :param size: the maximum size of the thumbnail as a ``(width, height)`` + tuple - .. method:: set_cache(cache) + .. method:: set_cache(cache: OpenSlideCache) -> None Use the specified :class:`OpenSlideCache` to store recently decoded slide tiles. By default, the :class:`OpenSlide` has a private cache with a default size. - :param OpenSlideCache cache: a cache object + :param cache: a cache object :raises OpenSlideVersionError: if OpenSlide is older than version 4.0.0 - .. method:: close() + .. method:: close() -> None Close the OpenSlide object. @@ -213,22 +231,21 @@ To include the profile in an image file when saving the image to disk:: image.save(filename, icc_profile=image.info.get('icc_profile')) To perform color conversions using the profile, import it into -:mod:`ImageCms `. For example, to convert an image in-place -to a synthesized sRGB profile, using absolute colorimetric rendering:: +:mod:`ImageCms `. For example, to synthesize an sRGB profile +and use it to transform an image for display, with the default rendering +intent of the image's profile:: from io import BytesIO from PIL import ImageCms fromProfile = ImageCms.getOpenProfile(BytesIO(image.info['icc_profile'])) toProfile = ImageCms.createProfile('sRGB') + intent = ImageCms.getDefaultIntent(fromProfile) ImageCms.profileToProfile( - image, fromProfile, toProfile, - ImageCms.Intent.ABSOLUTE_COLORIMETRIC, 'RGBA', True, 0 + image, fromProfile, toProfile, intent, 'RGBA', True, 0 ) -Absolute colorimetric rendering `maximizes the comparability`_ of images -produced by different scanners. When converting Deep Zoom tiles, use -``'RGB'`` instead of ``'RGBA'``. +When converting Deep Zoom tiles, use ``'RGB'`` instead of ``'RGBA'``. All pyramid regions in a slide have the same profile, but each associated image can have its own profile. As a convenience, the former is also @@ -238,20 +255,18 @@ by building an :class:`~PIL.ImageCms.ImageCmsTransform` for the slide and reusing it for multiple slide regions:: toProfile = ImageCms.createProfile('sRGB') + intent = ImageCms.getDefaultIntent(slide.color_profile) transform = ImageCms.buildTransform( - slide.color_profile, toProfile, 'RGBA', 'RGBA', - ImageCms.Intent.ABSOLUTE_COLORIMETRIC, 0 + slide.color_profile, toProfile, 'RGBA', 'RGBA', intent, 0 ) # for each region image: ImageCms.applyTransform(image, transform, True) -.. _maximizes the comparability: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4478790/ - Caching ------- -.. class:: OpenSlideCache(capacity) +.. class:: OpenSlideCache(capacity: int) An in-memory tile cache. @@ -259,7 +274,7 @@ Caching with :meth:`OpenSlide.set_cache` to cache recently-decoded tiles. By default, each :class:`OpenSlide` has its own cache with a default size. - :param int capacity: the cache capacity in bytes + :param capacity: the cache capacity in bytes :raises OpenSlideVersionError: if OpenSlide is older than version 4.0.0 @@ -332,7 +347,7 @@ Exceptions Once :exc:`OpenSlideError` has been raised by a particular :class:`OpenSlide`, all future operations on that :class:`OpenSlide` - (other than :meth:`close() `) will also raise + (other than :meth:`~OpenSlide.close`) will also raise :exc:`OpenSlideError`. .. exception:: OpenSlideUnsupportedFormatError @@ -351,20 +366,24 @@ Exceptions Wrapping a Pillow Image ======================= -.. class:: ImageSlide(file) +.. class:: AbstractSlide + + The abstract base class of :class:`OpenSlide` and :class:`ImageSlide`. - A wrapper around an :class:`Image ` object that - provides an :class:`OpenSlide`-compatible API. +.. class:: ImageSlide(file: str | bytes | ~os.PathLike[typing.Any] | ~PIL.Image.Image) - :param file: a filename or :class:`Image ` object + A wrapper around an :class:`~PIL.Image.Image` object that provides an + :class:`OpenSlide`-compatible API. + + :param file: a filename or :class:`~PIL.Image.Image` object :raises OSError: if the file cannot be opened -.. function:: open_slide(filename) +.. function:: open_slide(filename: str | bytes | ~os.PathLike[typing.Any]) -> OpenSlide | ImageSlide Return an :class:`OpenSlide` for whole-slide images and an :class:`ImageSlide` for other types of images. - :param str filename: the file to open + :param filename: the file to open :raises OpenSlideError: if the file is recognized by OpenSlide but an error occurred :raises OSError: if the file is not recognized at all @@ -382,72 +401,79 @@ Deep Zoom or a similar format. .. _`Deep Zoom`: https://docs.microsoft.com/en-us/previous-versions/windows/silverlight/dotnet-windows-silverlight/cc645050(v=vs.95) -.. class:: DeepZoomGenerator(osr, tile_size=254, overlap=1, limit_bounds=False) +.. class:: DeepZoomGenerator(osr: AbstractSlide, tile_size: int = 254, overlap: int = 1, limit_bounds: bool = False) - A Deep Zoom generator that wraps an - :class:`OpenSlide ` or - :class:`ImageSlide ` object. + A Deep Zoom generator that wraps an :class:`~openslide.OpenSlide` object, + :class:`~openslide.ImageSlide` object, or user-provided instance of + :class:`~openslide.AbstractSlide`. :param osr: the slide object - :param int tile_size: the width and height of a single tile. For best - viewer performance, ``tile_size + 2 * overlap`` should be a power of two. - :param int overlap: the number of extra pixels to add to each interior edge - of a tile - :param bool limit_bounds: ``True`` to render only the non-empty slide - region + :param tile_size: the width and height of a single tile. For best viewer + performance, ``tile_size + 2 * overlap`` should be a power of two. + :param overlap: the number of extra pixels to add to each interior edge of a + tile + :param limit_bounds: :obj:`True` to render only the non-empty slide region .. attribute:: level_count The number of Deep Zoom levels in the image. + :type: int + .. attribute:: tile_count The total number of Deep Zoom tiles in the image. + :type: int + .. attribute:: level_tiles - A list of ``(tiles_x, tiles_y)`` tuples for each Deep Zoom level. + A tuple of ``(tiles_x, tiles_y)`` tuples for each Deep Zoom level. ``level_tiles[k]`` are the tile counts of level ``k``. + :type: tuple[tuple[int, int], ...] + .. attribute:: level_dimensions - A list of ``(pixels_x, pixels_y)`` tuples for each Deep Zoom level. + A tuple of ``(pixels_x, pixels_y)`` tuples for each Deep Zoom level. ``level_dimensions[k]`` are the dimensions of level ``k``. - .. method:: get_dzi(format) + :type: tuple[tuple[int, int], ...] + + .. method:: get_dzi(format: str) -> str Return a string containing the XML metadata for the Deep Zoom ``.dzi`` file. - :param str format: the delivery format of the individual tiles - (``png`` or ``jpeg``) + :param format: the delivery format of the individual tiles (``png`` or + ``jpeg``) - .. method:: get_tile(level, address) + .. method:: get_tile(level: int, address: tuple[int, int]) -> ~PIL.Image.Image - Return an RGB :class:`Image ` for a tile. + Return an RGB :class:`~PIL.Image.Image` for a tile. - :param int level: the Deep Zoom level - :param tuple address: the address of the tile within the level as a + :param level: the Deep Zoom level + :param address: the address of the tile within the level as a ``(column, row)`` tuple - .. method:: get_tile_coordinates(level, address) + .. method:: get_tile_coordinates(level: int, address: tuple[int, int]) -> tuple[tuple[int, int], int, tuple[int, int]] Return the :meth:`OpenSlide.read_region() ` arguments corresponding to the specified tile. - Most applications should use :meth:`get_tile()` instead. + Most applications should use :meth:`get_tile` instead. - :param int level: the Deep Zoom level - :param tuple address: the address of the tile within the level as a + :param level: the Deep Zoom level + :param address: the address of the tile within the level as a ``(column, row)`` tuple - .. method:: get_tile_dimensions(level, address) + .. method:: get_tile_dimensions(level: int, address: tuple[int, int]) -> tuple[int, int] Return a ``(pixels_x, pixels_y)`` tuple for the specified tile. - :param int level: the Deep Zoom level - :param tuple address: the address of the tile within the level as a + :param level: the Deep Zoom level + :param address: the address of the tile within the level as a ``(column, row)`` tuple diff --git a/doc/jekyll_fix.py b/doc/jekyll_fix.py index a2b65128..d7fe8bf2 100644 --- a/doc/jekyll_fix.py +++ b/doc/jekyll_fix.py @@ -2,6 +2,7 @@ # openslide-python - Python bindings for the OpenSlide library # # Copyright (c) 2014 Carnegie Mellon University +# Copyright (c) 2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -13,8 +14,7 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # # Sphinx hardcodes that certain output paths have names starting with @@ -23,8 +23,12 @@ # deployed to the website. # Rename Sphinx output paths to drop the underscore. +from __future__ import annotations + import os +from pathlib import Path +from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.console import bold @@ -39,46 +43,42 @@ REWRITE_EXTENSIONS = {'.html', '.js'} -def remove_path_underscores(app, exception): +def remove_path_underscores(app: Sphinx, exception: Exception | None) -> None: if exception: return # Get logger logger = logging.getLogger(__name__) logger.info(bold('fixing pathnames... '), nonl=True) # Rewrite references in HTML/JS files - for dirpath, _, filenames in os.walk(app.outdir): + outdir = Path(app.outdir) + for dirpath, _, filenames in os.walk(outdir): for filename in filenames: - _, ext = os.path.splitext(filename) - if ext in REWRITE_EXTENSIONS: - path = os.path.join(dirpath, filename) - with open(path, encoding='utf-8') as fh: + path = Path(dirpath) / filename + if path.suffix in REWRITE_EXTENSIONS: + with path.open(encoding='utf-8') as fh: contents = fh.read() for old, new in DIRS.items(): contents = contents.replace(old + '/', new + '/') for old, new in FILES.items(): contents = contents.replace(old, new) - with open(path, 'w', encoding='utf-8') as fh: + with path.open('w', encoding='utf-8') as fh: fh.write(contents) # Move directory contents for old, new in DIRS.items(): - olddir = os.path.join(app.outdir, old) - newdir = os.path.join(app.outdir, new) - if not os.path.exists(newdir): - os.mkdir(newdir) - if os.path.isdir(olddir): - for filename in os.listdir(olddir): - oldfile = os.path.join(olddir, filename) - newfile = os.path.join(newdir, filename) - os.rename(oldfile, newfile) - os.rmdir(olddir) + olddir = outdir / old + newdir = outdir / new + newdir.mkdir(exist_ok=True) + if olddir.is_dir(): + for oldfile in olddir.iterdir(): + oldfile.rename(newdir / oldfile.name) + olddir.rmdir() # Move files for old, new in FILES.items(): - oldfile = os.path.join(app.outdir, old) - newfile = os.path.join(app.outdir, new) - if os.path.isfile(oldfile): - os.rename(oldfile, newfile) + oldfile = outdir / old + if oldfile.is_file(): + oldfile.rename(outdir / new) logger.info('done') -def setup(app): +def setup(app: Sphinx) -> None: app.connect('build-finished', remove_path_underscores) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 83c65427..0f43680e 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -3,7 +3,7 @@ # deepzoom_multiserver - Example web application for viewing multiple slides # # Copyright (c) 2010-2015 Carnegie Mellon University -# Copyright (c) 2021-2023 Benjamin Gilbert +# Copyright (c) 2021-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -15,25 +15,33 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + from argparse import ArgumentParser import base64 from collections import OrderedDict +from collections.abc import Callable from io import BytesIO import os +from pathlib import Path, PurePath from threading import Lock +from typing import TYPE_CHECKING, Any, Literal import zlib -from PIL import ImageCms -from flask import Flask, abort, make_response, render_template, url_for +from PIL import Image, ImageCms +from flask import Flask, Response, abort, make_response, render_template, url_for + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide @@ -60,10 +68,36 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + Transform: TypeAlias = Callable[[Image.Image], None] + + +class DeepZoomMultiServer(Flask): + basedir: Path + cache: _SlideCache + + +class AnnotatedDeepZoomGenerator(DeepZoomGenerator): + filename: str + mpp: float + transform: Transform -def create_app(config=None, config_file=None): + +def create_app( + config: dict[str, Any] | None = None, + config_file: Path | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomMultiServer(__name__) app.config.from_mapping( SLIDE_DIR='.', SLIDE_CACHE_SIZE=10, @@ -73,7 +107,7 @@ def create_app(config=None, config_file=None): DEEPZOOM_OVERLAP=1, DEEPZOOM_LIMIT_BOUNDS=True, DEEPZOOM_TILE_QUALITY=75, - DEEPZOOM_COLOR_MODE='absolute-colorimetric', + DEEPZOOM_COLOR_MODE='default', ) app.config.from_envvar('DEEPZOOM_MULTISERVER_SETTINGS', silent=True) if config_file is not None: @@ -82,7 +116,7 @@ def create_app(config=None, config_file=None): app.config.from_mapping(config) # Set up cache - app.basedir = os.path.abspath(app.config['SLIDE_DIR']) + app.basedir = Path(app.config['SLIDE_DIR']).resolve(strict=True) config_map = { 'DEEPZOOM_TILE_SIZE': 'tile_size', 'DEEPZOOM_OVERLAP': 'overlap', @@ -97,28 +131,30 @@ def create_app(config=None, config_file=None): ) # Helper functions - def get_slide(path): - path = os.path.abspath(os.path.join(app.basedir, path)) - if not path.startswith(app.basedir + os.path.sep): - # Directory traversal + def get_slide(user_path: PurePath) -> AnnotatedDeepZoomGenerator: + try: + path = (app.basedir / user_path).resolve(strict=True) + except OSError: + # Does not exist abort(404) - if not os.path.exists(path): + if path.parts[: len(app.basedir.parts)] != app.basedir.parts: + # Directory traversal abort(404) try: slide = app.cache.get(path) - slide.filename = os.path.basename(path) + slide.filename = path.name return slide except OpenSlideError: abort(404) # Set up routes @app.route('/') - def index(): + def index() -> str: return render_template('files.html', root_dir=_Directory(app.basedir)) @app.route('/') - def slide(path): - slide = get_slide(path) + def slide(path: str) -> str: + slide = get_slide(PurePath(path)) slide_url = url_for('dzi', path=path) return render_template( 'slide-fullpage.html', @@ -128,16 +164,16 @@ def slide(path): ) @app.route('/.dzi') - def dzi(path): - slide = get_slide(path) + def dzi(path: str) -> Response: + slide = get_slide(PurePath(path)) format = app.config['DEEPZOOM_FORMAT'] resp = make_response(slide.get_dzi(format)) resp.mimetype = 'application/xml' return resp @app.route('/_files//_.') - def tile(path, level, col, row, format): - slide = get_slide(path) + def tile(path: str, level: int, col: int, row: int, format: str) -> Response: + slide = get_slide(PurePath(path)) format = format.lower() if format != 'jpeg' and format != 'png': # Not supported by Deep Zoom @@ -163,19 +199,27 @@ def tile(path, level, col, row, format): class _SlideCache: - def __init__(self, cache_size, tile_cache_mb, dz_opts, color_mode): + def __init__( + self, + cache_size: int, + tile_cache_mb: int, + dz_opts: dict[str, Any], + color_mode: ColorMode, + ): self.cache_size = cache_size self.dz_opts = dz_opts self.color_mode = color_mode self._lock = Lock() - self._cache = OrderedDict() + self._cache: OrderedDict[Path, AnnotatedDeepZoomGenerator] = OrderedDict() # Share a single tile cache among all slide handles, if supported try: - self._tile_cache = OpenSlideCache(tile_cache_mb * 1024 * 1024) + self._tile_cache: OpenSlideCache | None = OpenSlideCache( + tile_cache_mb * 1024 * 1024 + ) except OpenSlideVersionError: self._tile_cache = None - def get(self, path): + def get(self, path: Path) -> AnnotatedDeepZoomGenerator: with self._lock: if path in self._cache: # Move to end of LRU @@ -186,7 +230,7 @@ def get(self, path): osr = OpenSlide(path) if self._tile_cache is not None: osr.set_cache(self._tile_cache) - slide = DeepZoomGenerator(osr, **self.dz_opts) + slide = AnnotatedDeepZoomGenerator(osr, **self.dz_opts) try: mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y] @@ -202,7 +246,7 @@ def get(self, path): self._cache[path] = slide return slide - def _get_transform(self, image): + def _get_transform(self, image: OpenSlide) -> Transform: if image.color_profile is None: return lambda img: None mode = self.color_mode @@ -212,6 +256,8 @@ def _get_transform(self, image): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -228,10 +274,10 @@ def _get_transform(self, image): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we # don't embed the profile. Pillow's serialization is larger, so @@ -242,13 +288,14 @@ def xfrm(img): class _Directory: - def __init__(self, basedir, relpath=''): - self.name = os.path.basename(relpath) - self.children = [] - for name in sorted(os.listdir(os.path.join(basedir, relpath))): - cur_relpath = os.path.join(relpath, name) - cur_path = os.path.join(basedir, cur_relpath) - if os.path.isdir(cur_path): + _DEFAULT_RELPATH = PurePath('.') + + def __init__(self, basedir: Path, relpath: PurePath = _DEFAULT_RELPATH): + self.name = relpath.name + self.children: list[_Directory | _SlideFile] = [] + for cur_path in sorted((basedir / relpath).iterdir()): + cur_relpath = relpath / cur_path.name + if cur_path.is_dir(): cur_dir = _Directory(basedir, cur_relpath) if cur_dir.children: self.children.append(cur_dir) @@ -257,9 +304,9 @@ def __init__(self, basedir, relpath=''): class _SlideFile: - def __init__(self, relpath): - self.name = os.path.basename(relpath) - self.url_path = relpath + def __init__(self, relpath: PurePath): + self.name = relpath.name + self.url_path = relpath.as_posix() if __name__ == '__main__': @@ -276,6 +323,7 @@ def __init__(self, relpath): '--color-mode', dest='DEEPZOOM_COLOR_MODE', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -283,15 +331,15 @@ def __init__(self, relpath): 'embed', 'ignore', ], - default='absolute-colorimetric', + default='default', help=( - 'convert tiles to sRGB using specified rendering intent, or ' - 'embed original ICC profile, or ignore ICC profile (compat) ' - '[absolute-colorimetric]' + 'convert tiles to sRGB using default rendering intent of ICC ' + 'profile, or specified rendering intent; or embed original ' + 'ICC profile; or ignore ICC profile (compat) [default]' ), ) parser.add_argument( - '-c', '--config', metavar='FILE', dest='config', help='config file' + '-c', '--config', metavar='FILE', type=Path, dest='config', help='config file' ) parser.add_argument( '-d', @@ -311,8 +359,8 @@ def __init__(self, relpath): parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='DEEPZOOM_FORMAT', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( @@ -351,6 +399,7 @@ def __init__(self, relpath): parser.add_argument( 'SLIDE_DIR', metavar='SLIDE-DIRECTORY', + type=Path, nargs='?', help='slide directory', ) diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index 234b15bc..bde5a879 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -15,32 +15,40 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + from argparse import ArgumentParser import base64 +from collections.abc import Callable, Mapping from io import BytesIO import os +from pathlib import Path import re +from typing import TYPE_CHECKING, Any, Literal from unicodedata import normalize import zlib -from PIL import ImageCms -from flask import Flask, abort, make_response, render_template, url_for +from PIL import Image, ImageCms +from flask import Flask, Response, abort, make_response, render_template, url_for + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide else: import openslide -from openslide import ImageSlide, open_slide +from openslide import AbstractSlide, ImageSlide, open_slide from openslide.deepzoom import DeepZoomGenerator SLIDE_NAME = 'slide' @@ -62,10 +70,33 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + Transform: TypeAlias = Callable[[Image.Image], None] + + +class DeepZoomServer(Flask): + slides: dict[str, DeepZoomGenerator] + transforms: dict[str, Transform] + slide_properties: Mapping[str, str] + associated_images: list[str] + slide_mpp: float + -def create_app(config=None, config_file=None): +def create_app( + config: dict[str, Any] | None = None, + config_file: Path | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomServer(__name__) app.config.from_mapping( DEEPZOOM_SLIDE=None, DEEPZOOM_FORMAT='jpeg', @@ -73,7 +104,7 @@ def create_app(config=None, config_file=None): DEEPZOOM_OVERLAP=1, DEEPZOOM_LIMIT_BOUNDS=True, DEEPZOOM_TILE_QUALITY=75, - DEEPZOOM_COLOR_MODE='absolute-colorimetric', + DEEPZOOM_COLOR_MODE='default', ) app.config.from_envvar('DEEPZOOM_TILER_SETTINGS', silent=True) if config_file is not None: @@ -82,9 +113,9 @@ def create_app(config=None, config_file=None): app.config.from_mapping(config) # Open slide - slidefile = app.config['DEEPZOOM_SLIDE'] - if slidefile is None: + if app.config['DEEPZOOM_SLIDE'] is None: raise ValueError('No slide file specified') + slidefile = Path(app.config['DEEPZOOM_SLIDE']) config_map = { 'DEEPZOOM_TILE_SIZE': 'tile_size', 'DEEPZOOM_OVERLAP': 'overlap', @@ -115,7 +146,7 @@ def create_app(config=None, config_file=None): # Set up routes @app.route('/') - def index(): + def index() -> str: slide_url = url_for('dzi', slug=SLIDE_NAME) associated_urls = { name: url_for('dzi', slug=slugify(name)) for name in app.associated_images @@ -129,7 +160,7 @@ def index(): ) @app.route('/.dzi') - def dzi(slug): + def dzi(slug: str) -> Response: format = app.config['DEEPZOOM_FORMAT'] try: resp = make_response(app.slides[slug].get_dzi(format)) @@ -140,7 +171,7 @@ def dzi(slug): abort(404) @app.route('/_files//_.') - def tile(slug, level, col, row, format): + def tile(slug: str, level: int, col: int, row: int, format: str) -> Response: format = format.lower() if format != 'jpeg' and format != 'png': # Not supported by Deep Zoom @@ -168,12 +199,12 @@ def tile(slug, level, col, row, format): return app -def slugify(text): +def slugify(text: str) -> str: text = normalize('NFKD', text.lower()).encode('ascii', 'ignore').decode() return re.sub('[^a-z0-9]+', '-', text) -def get_transform(image, mode): +def get_transform(image: AbstractSlide, mode: ColorMode) -> Transform: if image.color_profile is None: return lambda img: None if mode == 'ignore': @@ -182,6 +213,8 @@ def get_transform(image, mode): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -198,10 +231,10 @@ def get_transform(image, mode): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we don't # embed the profile. Pillow's serialization is larger, so use ours. @@ -224,6 +257,7 @@ def xfrm(img): '--color-mode', dest='DEEPZOOM_COLOR_MODE', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -231,15 +265,15 @@ def xfrm(img): 'embed', 'ignore', ], - default='absolute-colorimetric', + default='default', help=( - 'convert tiles to sRGB using specified rendering intent, or ' - 'embed original ICC profile, or ignore ICC profile (compat) ' - '[absolute-colorimetric]' + 'convert tiles to sRGB using default rendering intent of ICC ' + 'profile, or specified rendering intent; or embed original ' + 'ICC profile; or ignore ICC profile (compat) [default]' ), ) parser.add_argument( - '-c', '--config', metavar='FILE', dest='config', help='config file' + '-c', '--config', metavar='FILE', type=Path, dest='config', help='config file' ) parser.add_argument( '-d', @@ -259,8 +293,8 @@ def xfrm(img): parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='DEEPZOOM_FORMAT', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( @@ -299,6 +333,7 @@ def xfrm(img): parser.add_argument( 'DEEPZOOM_SLIDE', metavar='SLIDE', + type=Path, nargs='?', help='slide file', ) diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 739a40cb..65b75b3e 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -3,7 +3,7 @@ # deepzoom_tile - Convert whole-slide images to Deep Zoom format # # Copyright (c) 2010-2015 Carnegie Mellon University -# Copyright (c) 2022-2023 Benjamin Gilbert +# Copyright (c) 2022-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -15,37 +15,46 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # """An example program to generate a Deep Zoom directory tree from a slide.""" +from __future__ import annotations + from argparse import ArgumentParser import base64 +from collections.abc import Callable from io import BytesIO import json from multiprocessing import JoinableQueue, Process +import multiprocessing.queues import os +from pathlib import Path import re import shutil import sys +from typing import TYPE_CHECKING, Literal from unicodedata import normalize import zlib -from PIL import ImageCms +from PIL import Image, ImageCms + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide else: import openslide -from openslide import ImageSlide, open_slide +from openslide import AbstractSlide, ImageSlide, open_slide from openslide.deepzoom import DeepZoomGenerator VIEWER_SLIDE_NAME = 'slide' @@ -67,12 +76,34 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + TileQueue: TypeAlias = multiprocessing.queues.JoinableQueue[ + tuple[str | None, int, tuple[int, int], Path] | None + ] + Transform: TypeAlias = Callable[[Image.Image], None] + class TileWorker(Process): """A child process that generates and writes tiles.""" def __init__( - self, queue, slidepath, tile_size, overlap, limit_bounds, quality, color_mode + self, + queue: TileQueue, + slidepath: Path, + tile_size: int, + overlap: int, + limit_bounds: bool, + quality: int, + color_mode: ColorMode, ): Process.__init__(self, name='TileWorker') self.daemon = True @@ -83,9 +114,9 @@ def __init__( self._limit_bounds = limit_bounds self._quality = quality self._color_mode = color_mode - self._slide = None + self._slide: AbstractSlide | None = None - def run(self): + def run(self) -> None: self._slide = open_slide(self._slidepath) last_associated = None dz, transform = self._get_dz_and_transform() @@ -105,9 +136,12 @@ def run(self): ) self._queue.task_done() - def _get_dz_and_transform(self, associated=None): + def _get_dz_and_transform( + self, associated: str | None = None + ) -> tuple[DeepZoomGenerator, Transform]: + assert self._slide is not None if associated is not None: - image = ImageSlide(self._slide.associated_images[associated]) + image: AbstractSlide = ImageSlide(self._slide.associated_images[associated]) else: image = self._slide dz = DeepZoomGenerator( @@ -115,7 +149,7 @@ def _get_dz_and_transform(self, associated=None): ) return dz, self._get_transform(image) - def _get_transform(self, image): + def _get_transform(self, image: AbstractSlide) -> Transform: if image.color_profile is None: return lambda img: None mode = self._color_mode @@ -125,6 +159,8 @@ def _get_transform(self, image): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -141,10 +177,10 @@ def _get_transform(self, image): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we # don't embed the profile. Pillow's serialization is larger, so @@ -157,7 +193,14 @@ def xfrm(img): class DeepZoomImageTiler: """Handles generation of tiles and metadata for a single image.""" - def __init__(self, dz, basename, format, associated, queue): + def __init__( + self, + dz: DeepZoomGenerator, + basename: Path, + format: str, + associated: str | None, + queue: TileQueue, + ): self._dz = dz self._basename = basename self._format = format @@ -165,26 +208,25 @@ def __init__(self, dz, basename, format, associated, queue): self._queue = queue self._processed = 0 - def run(self): + def run(self) -> None: self._write_tiles() self._write_dzi() - def _write_tiles(self): + def _write_tiles(self) -> None: for level in range(self._dz.level_count): - tiledir = os.path.join("%s_files" % self._basename, str(level)) - if not os.path.exists(tiledir): - os.makedirs(tiledir) + tiledir = self._basename.with_name(self._basename.name + '_files') / str( + level + ) + tiledir.mkdir(parents=True, exist_ok=True) cols, rows = self._dz.level_tiles[level] for row in range(rows): for col in range(cols): - tilename = os.path.join( - tiledir, '%d_%d.%s' % (col, row, self._format) - ) - if not os.path.exists(tilename): + tilename = tiledir / f'{col}_{row}.{self._format}' + if not tilename.exists(): self._queue.put((self._associated, level, (col, row), tilename)) self._tile_done() - def _tile_done(self): + def _tile_done(self) -> None: self._processed += 1 count, total = self._processed, self._dz.tile_count if count % 100 == 0 or count == total: @@ -197,11 +239,11 @@ def _tile_done(self): if count == total: print(file=sys.stderr) - def _write_dzi(self): - with open('%s.dzi' % self._basename, 'w') as fh: + def _write_dzi(self) -> None: + with self._basename.with_name(self._basename.name + '.dzi').open('w') as fh: fh.write(self.get_dzi()) - def get_dzi(self): + def get_dzi(self) -> str: return self._dz.get_dzi(self._format) @@ -210,16 +252,16 @@ class DeepZoomStaticTiler: def __init__( self, - slidepath, - basename, - format, - tile_size, - overlap, - limit_bounds, - quality, - color_mode, - workers, - with_viewer, + slidepath: Path, + basename: Path, + format: str, + tile_size: int, + overlap: int, + limit_bounds: bool, + quality: int, + color_mode: ColorMode, + workers: int, + with_viewer: bool, ): if with_viewer: # Check extra dependency before doing a bunch of work @@ -230,11 +272,11 @@ def __init__( self._tile_size = tile_size self._overlap = overlap self._limit_bounds = limit_bounds - self._queue = JoinableQueue(2 * workers) + self._queue: TileQueue = JoinableQueue(2 * workers) self._workers = workers self._color_mode = color_mode self._with_viewer = with_viewer - self._dzi_data = {} + self._dzi_data: dict[str, str] = {} for _i in range(workers): TileWorker( self._queue, @@ -246,7 +288,7 @@ def __init__( color_mode, ).start() - def run(self): + def run(self) -> None: self._run_image() if self._with_viewer: for name in self._slide.associated_images: @@ -255,17 +297,17 @@ def run(self): self._write_static() self._shutdown() - def _run_image(self, associated=None): + def _run_image(self, associated: str | None = None) -> None: """Run a single image from self._slide.""" if associated is None: image = self._slide if self._with_viewer: - basename = os.path.join(self._basename, VIEWER_SLIDE_NAME) + basename = self._basename / VIEWER_SLIDE_NAME else: basename = self._basename else: image = ImageSlide(self._slide.associated_images[associated]) - basename = os.path.join(self._basename, self._slugify(associated)) + basename = self._basename / self._slugify(associated) dz = DeepZoomGenerator( image, self._tile_size, self._overlap, limit_bounds=self._limit_bounds ) @@ -273,14 +315,14 @@ def _run_image(self, associated=None): tiler.run() self._dzi_data[self._url_for(associated)] = tiler.get_dzi() - def _url_for(self, associated): + def _url_for(self, associated: str | None) -> str: if associated is None: base = VIEWER_SLIDE_NAME else: base = self._slugify(associated) return '%s.dzi' % base - def _write_html(self): + def _write_html(self) -> None: import jinja2 # https://docs.python.org/3/reference/import.html#main-spec @@ -292,9 +334,7 @@ def _write_html(self): # We're not running from a module (e.g. "python deepzoom_tile.py") # so PackageLoader('__main__') doesn't work in jinja2 3.x. # Load templates directly from the filesystem. - loader = jinja2.FileSystemLoader( - os.path.join(os.path.dirname(__file__), 'templates') - ) + loader = jinja2.FileSystemLoader([Path(__file__).parent / 'templates']) env = jinja2.Environment(loader=loader, autoescape=True) template = env.get_template('slide-multipane.html') associated_urls = {n: self._url_for(n) for n in self._slide.associated_images} @@ -314,35 +354,46 @@ def _write_html(self): properties=self._slide.properties, dzi_data=json.dumps(self._dzi_data), ) - with open(os.path.join(self._basename, 'index.html'), 'w') as fh: + with open(self._basename / 'index.html', 'w') as fh: fh.write(data) - def _write_static(self): - basesrc = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') - basedst = os.path.join(self._basename, 'static') + def _write_static(self) -> None: + basesrc = Path(__file__).absolute().parent / 'static' + basedst = self._basename / 'static' self._copydir(basesrc, basedst) - self._copydir(os.path.join(basesrc, 'images'), os.path.join(basedst, 'images')) + self._copydir(basesrc / 'images', basedst / 'images') - def _copydir(self, src, dest): - if not os.path.exists(dest): - os.makedirs(dest) - for name in os.listdir(src): - srcpath = os.path.join(src, name) - if os.path.isfile(srcpath): - shutil.copy(srcpath, os.path.join(dest, name)) + def _copydir(self, src: Path, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + for srcpath in src.iterdir(): + if srcpath.is_file(): + shutil.copy(srcpath, dest / srcpath.name) @classmethod - def _slugify(cls, text): + def _slugify(cls, text: str) -> str: text = normalize('NFKD', text.lower()).encode('ascii', 'ignore').decode() return re.sub('[^a-z0-9]+', '_', text) - def _shutdown(self): + def _shutdown(self) -> None: for _i in range(self._workers): self._queue.put(None) self._queue.join() if __name__ == '__main__': + try: + # Python 3.13+ + available_cpus = os.process_cpu_count() # type: ignore[attr-defined] + except AttributeError: + try: + # Linux + available_cpus = len( + os.sched_getaffinity(0) # type: ignore[attr-defined,unused-ignore] + ) + except AttributeError: + # default + available_cpus = 4 + parser = ArgumentParser(usage='%(prog)s [options] ') parser.add_argument( '-B', @@ -356,6 +407,7 @@ def _shutdown(self): '--color-mode', dest='color_mode', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -363,11 +415,11 @@ def _shutdown(self): 'embed', 'ignore', ], - default='absolute-colorimetric', + default='default', help=( - 'convert tiles to sRGB using specified rendering intent, or ' - 'embed original ICC profile, or ignore ICC profile (compat) ' - '[absolute-colorimetric]' + 'convert tiles to sRGB using default rendering intent of ICC ' + 'profile, or specified rendering intent; or embed original ' + 'ICC profile; or ignore ICC profile (compat) [default]' ), ) parser.add_argument( @@ -382,9 +434,9 @@ def _shutdown(self): parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='format', default='jpeg', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( @@ -393,13 +445,14 @@ def _shutdown(self): metavar='COUNT', dest='workers', type=int, - default=4, - help='number of worker processes to start [4]', + default=available_cpus, + help=f'number of worker processes to start [{available_cpus}]', ) parser.add_argument( '-o', '--output', metavar='NAME', + type=Path, dest='basename', help='base name of output file', ) @@ -431,12 +484,13 @@ def _shutdown(self): parser.add_argument( 'slidepath', metavar='SLIDE', + type=Path, help='slide file', ) args = parser.parse_args() if args.basename is None: - args.basename = os.path.splitext(os.path.basename(args.slidepath))[0] + args.basename = Path(args.slidepath.stem) DeepZoomStaticTiler( args.slidepath, diff --git a/examples/deepzoom/licenses/LICENSE.jquery b/examples/deepzoom/licenses/LICENSE.jquery new file mode 100644 index 00000000..f642c3f7 --- /dev/null +++ b/examples/deepzoom/licenses/LICENSE.jquery @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/examples/deepzoom/licenses/LICENSE.openseadragon b/examples/deepzoom/licenses/LICENSE.openseadragon new file mode 100644 index 00000000..247d11af --- /dev/null +++ b/examples/deepzoom/licenses/LICENSE.openseadragon @@ -0,0 +1,28 @@ +Copyright (C) 2009 CodePlex Foundation +Copyright (C) 2010-2024 OpenSeadragon contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of CodePlex Foundation nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/deepzoom/licenses/LICENSE.openseadragon-scalebar b/examples/deepzoom/licenses/LICENSE.openseadragon-scalebar new file mode 100644 index 00000000..1625fc0e --- /dev/null +++ b/examples/deepzoom/licenses/LICENSE.openseadragon-scalebar @@ -0,0 +1,9 @@ +This software was developed at the National Institute of Standards and +Technology by employees of the Federal Government in the course of +their official duties. Pursuant to title 17 Section 105 of the United +States Code this software is not subject to copyright protection and is +in the public domain. This software is an experimental system. NIST assumes +no responsibility whatsoever for its use by other parties, and makes no +guarantees, expressed or implied, about its quality, reliability, or +any other characteristic. We would appreciate acknowledgement if the +software is used. diff --git a/examples/deepzoom/static/jquery.js b/examples/deepzoom/static/jquery.js index 15a1a291..f122b10d 100644 --- a/examples/deepzoom/static/jquery.js +++ b/examples/deepzoom/static/jquery.js @@ -1,12 +1,12 @@ /*! - * jQuery JavaScript Library v3.7.0 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/animatedSelector,-effects/Tween + * jQuery JavaScript Library v3.7.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/animatedSelector,-effects/Tween * https://jquery.com/ * * Copyright OpenJS Foundation and other contributors * Released under the MIT license * https://jquery.org/license * - * Date: 2023-05-11T18:29Z + * Date: 2023-08-28T13:37Z */ ( function( global, factory ) { @@ -147,7 +147,7 @@ function toType( obj ) { -var version = "3.7.0 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/animatedSelector,-effects/Tween", +var version = "3.7.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/animatedSelector,-effects/Tween", rhtmlSuffix = /HTML$/i, @@ -411,9 +411,14 @@ jQuery.extend( { // Do not traverse comment nodes ret += jQuery.text( node ); } - } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + } + if ( nodeType === 1 || nodeType === 11 ) { return elem.textContent; - } else if ( nodeType === 3 || nodeType === 4 ) { + } + if ( nodeType === 9 ) { + return elem.documentElement.textContent; + } + if ( nodeType === 3 || nodeType === 4 ) { return elem.nodeValue; } @@ -1126,12 +1131,17 @@ function setDocument( node ) { documentElement.msMatchesSelector; // Support: IE 9 - 11+, Edge 12 - 18+ - // Accessing iframe documents after unload throws "permission denied" errors (see trac-13936) - // Support: IE 11+, Edge 17 - 18+ - // IE/Edge sometimes throw a "Permission denied" error when strict-comparing - // two documents; shallow comparisons work. - // eslint-disable-next-line eqeqeq - if ( preferredDoc != document && + // Accessing iframe documents after unload throws "permission denied" errors + // (see trac-13936). + // Limit the fix to IE & Edge Legacy; despite Edge 15+ implementing `matches`, + // all IE 9+ and Edge Legacy versions implement `msMatchesSelector` as well. + if ( documentElement.msMatchesSelector && + + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + preferredDoc != document && ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { // Support: IE 9 - 11+, Edge 12 - 18+ @@ -2694,12 +2704,12 @@ jQuery.find = find; jQuery.expr[ ":" ] = jQuery.expr.pseudos; jQuery.unique = jQuery.uniqueSort; -// These have always been private, but they used to be documented -// as part of Sizzle so let's maintain them in the 3.x line -// for backwards compatibility purposes. +// These have always been private, but they used to be documented as part of +// Sizzle so let's maintain them for now for backwards compatibility purposes. find.compile = compile; find.select = select; find.setDocument = setDocument; +find.tokenize = tokenize; find.escape = jQuery.escapeSelector; find.getText = jQuery.text; @@ -5913,7 +5923,7 @@ function domManip( collection, args, callback, ignored ) { if ( hasScripts ) { doc = scripts[ scripts.length - 1 ].ownerDocument; - // Reenable scripts + // Re-enable scripts jQuery.map( scripts, restoreScript ); // Evaluate executable scripts on first document insertion @@ -6370,7 +6380,7 @@ var rboxStyle = new RegExp( cssExpand.join( "|" ), "i" ); trChild = document.createElement( "div" ); table.style.cssText = "position:absolute;left:-11111px;border-collapse:separate"; - tr.style.cssText = "border:1px solid"; + tr.style.cssText = "box-sizing:content-box;border:1px solid"; // Support: Chrome 86+ // Height set through cssText does not get applied. @@ -6382,7 +6392,7 @@ var rboxStyle = new RegExp( cssExpand.join( "|" ), "i" ); // In our bodyBackground.html iframe, // display for all div elements is set to "inline", // which causes a problem only in Android 8 Chrome 86. - // Ensuring the div is display: block + // Ensuring the div is `display: block` // gets around this issue. trChild.style.display = "block"; @@ -8451,7 +8461,9 @@ jQuery.fn.extend( { }, hover: function( fnOver, fnOut ) { - return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); + return this + .on( "mouseenter", fnOver ) + .on( "mouseleave", fnOut || fnOver ); } } ); diff --git a/examples/deepzoom/static/openseadragon.js b/examples/deepzoom/static/openseadragon.js index cd41170d..8b887076 100644 --- a/examples/deepzoom/static/openseadragon.js +++ b/examples/deepzoom/static/openseadragon.js @@ -1,6 +1,6 @@ -//! openseadragon 4.1.0 -//! Built on 2023-05-25 -//! Git commit: v4.1.0-0-8849681 +//! openseadragon 5.0.1 +//! Built on 2024-12-09 +//! Git commit: v5.0.1-0-480de92d //! http://openseadragon.github.io //! License: http://openseadragon.github.io/license/ @@ -8,7 +8,7 @@ * OpenSeadragon * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -66,7 +66,7 @@ /* * Portions of this source file taken from mattsnider.com: * - * Copyright (c) 2006-2022 Matt Snider + * Copyright (c) 2006-2013 Matt Snider * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), @@ -90,7 +90,7 @@ /** * @namespace OpenSeadragon - * @version openseadragon 4.1.0 + * @version openseadragon 5.0.1 * @classdesc The root namespace for OpenSeadragon. All utility methods * and classes are defined on or below this namespace. * @@ -196,6 +196,16 @@ * Zoom level to use when image is first opened or the home button is clicked. * If 0, adjusts to fit viewer. * + * @property {String|DrawerImplementation|Array} [drawer = ['webgl', 'canvas', 'html']] + * Which drawer to use. Valid strings are 'webgl', 'canvas', and 'html'. Valid drawer + * implementations are constructors of classes that extend OpenSeadragon.DrawerBase. + * An array of strings and/or constructors can be used to indicate the priority + * of different implementations, which will be tried in order based on browser support. + * + * @property {Object} drawerOptions + * Options to pass to the selected drawer implementation. For details + * please see {@link OpenSeadragon.DrawerOptions}. + * * @property {Number} [opacity=1] * Default proportional opacity of the tiled images (1=opaque, 0=hidden) * Hidden images do not draw and only load when preloading is allowed. @@ -210,9 +220,9 @@ * For complete list of modes, please @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation/ globalCompositeOperation} * * @property {Boolean} [imageSmoothingEnabled=true] - * Image smoothing for canvas rendering (only if canvas is used). Note: Ignored + * Image smoothing for rendering (only if the canvas or webgl drawer is used). Note: Ignored * by some (especially older) browsers which do not support this canvas property. - * This property can be changed in {@link Viewer.Drawer.setImageSmoothingEnabled}. + * This property can be changed in {@link Viewer.DrawerBase.setImageSmoothingEnabled}. * * @property {String|CanvasGradient|CanvasPattern|Function} [placeholderFillStyle=null] * Draws a colored rectangle behind the tile if it is not loaded yet. @@ -236,6 +246,11 @@ * @property {Boolean} [flipped=false] * Initial flip state. * + * @property {Boolean} [overlayPreserveContentDirection=true] + * When the viewport is flipped (by pressing 'f'), the overlay is flipped using ScaleX. + * Normally, this setting (default true) keeps the overlay's content readable by flipping it back. + * To make the content flip with the overlay, set overlayPreserveContentDirection to false. + * * @property {Number} [minZoomLevel=null] * * @property {Number} [maxZoomLevel=null] @@ -296,6 +311,12 @@ * @property {Number} [rotationIncrement=90] * The number of degrees to rotate right or left when the rotate buttons or keyboard shortcuts are activated. * + * @property {Number} [maxTilesPerFrame=1] + * The number of tiles loaded per frame. As the frame rate of the client's machine is usually high (e.g., 50 fps), + * one tile per frame should be a good choice. However, for large screens or lower frame rates, the number of + * loaded tiles per frame can be adjusted here. Reasonable values might be 2 or 3 tiles per frame. + * (Note that the actual frame rate is given by the client's browser and machine). + * * @property {Number} [pixelsPerWheelLine=40] * For pixel-resolution scrolling devices, the number of pixels equal to one scroll line. * @@ -508,7 +529,7 @@ * Milliseconds to wait after each tile retry if tileRetryMax is set. * * @property {Boolean} [useCanvas=true] - * Set to false to not use an HTML canvas element for image rendering even if canvas is supported. + * Deprecated. Use the `drawer` option to specify preferred renderer. * * @property {Number} [minPixelRatio=0.5] * The higher the minPixelRatio, the lower the quality of the image that @@ -744,6 +765,16 @@ * */ + /** + * @typedef {Object} DrawerOptions + * @memberof OpenSeadragon + * @property {Object} webgl - options if the WebGLDrawer is used. No options are currently supported. + * @property {Object} canvas - options if the CanvasDrawer is used. No options are currently supported. + * @property {Object} html - options if the HTMLDrawer is used. No options are currently supported. + * @property {Object} custom - options if a custom drawer is used. No options are currently supported. + */ + + /** * The names for the image resources used for the image navigation buttons. * @@ -825,10 +856,10 @@ function OpenSeadragon( options ){ * @since 1.0.0 */ $.version = { - versionStr: '4.1.0', - major: parseInt('4', 10), - minor: parseInt('1', 10), - revision: parseInt('0', 10) + versionStr: '5.0.1', + major: parseInt('5', 10), + minor: parseInt('0', 10), + revision: parseInt('1', 10) }; @@ -1044,8 +1075,9 @@ function OpenSeadragon( options ){ /** * A ratio comparing the device screen's pixel density to the canvas's backing store pixel density, * clamped to a minimum of 1. Defaults to 1 if canvas isn't supported by the browser. - * @member {Number} pixelDensityRatio + * @function getCurrentPixelDensityRatio * @memberof OpenSeadragon + * @returns {Number} */ $.getCurrentPixelDensityRatio = function() { if ( $.supportsCanvas ) { @@ -1063,6 +1095,8 @@ function OpenSeadragon( options ){ }; /** + * A ratio comparing the device screen's pixel density to the canvas's backing store pixel density, + * clamped to a minimum of 1. Defaults to 1 if canvas isn't supported by the browser. * @member {Number} pixelDensityRatio * @memberof OpenSeadragon */ @@ -1294,6 +1328,7 @@ function OpenSeadragon( options ){ preserveImageSizeOnResize: false, // requires autoResize=true minScrollDeltaTime: 50, rotationIncrement: 90, + maxTilesPerFrame: 1, //DEFAULT CONTROL SETTINGS showSequenceControl: true, //SEQUENCE @@ -1335,15 +1370,36 @@ function OpenSeadragon( options ){ degrees: 0, // INITIAL FLIP STATE - flipped: false, + flipped: false, + overlayPreserveContentDirection: true, // APPEARANCE - opacity: 1, - preload: false, - compositeOperation: null, - imageSmoothingEnabled: true, - placeholderFillStyle: null, - subPixelRoundingForTransparency: null, + opacity: 1, // to be passed into each TiledImage + compositeOperation: null, // to be passed into each TiledImage + + // DRAWER SETTINGS + drawer: ['webgl', 'canvas', 'html'], // prefer using webgl, then canvas (i.e. context2d), then fallback to html + + drawerOptions: { + webgl: { + + }, + canvas: { + + }, + html: { + + }, + custom: { + + } + }, + + // TILED IMAGE SETTINGS + preload: false, // to be passed into each TiledImage + imageSmoothingEnabled: true, // to be passed into each TiledImage + placeholderFillStyle: null, // to be passed into each TiledImage + subPixelRoundingForTransparency: null, // to be passed into each TiledImage //REFERENCE STRIP SETTINGS showReferenceStrip: false, @@ -1366,7 +1422,6 @@ function OpenSeadragon( options ){ imageLoaderLimit: 0, maxImageCacheCount: 200, timeout: 30000, - useCanvas: true, // Use canvas element for drawing if available tileRetryMax: 0, tileRetryDelay: 2500, @@ -1436,16 +1491,6 @@ function OpenSeadragon( options ){ }, - - /** - * TODO: get rid of this. I can't see how it's required at all. Looks - * like an early legacy code artifact. - * @static - * @ignore - */ - SIGNAL: "----seadragon----", - - /** * Returns a function which invokes the method as if it were a method belonging to the object. * @function @@ -2257,25 +2302,12 @@ function OpenSeadragon( options ){ event.stopPropagation(); }, - - /** - * Similar to OpenSeadragon.delegate, but it does not immediately call - * the method on the object, returning a function which can be called - * repeatedly to delegate the method. It also allows additional arguments - * to be passed during construction which will be added during each - * invocation, and each invocation can add additional arguments as well. - * - * @function - * @param {Object} object - * @param {Function} method - * @param [args] any additional arguments are passed as arguments to the - * created callback - * @returns {Function} - */ + // Deprecated createCallback: function( object, method ) { //TODO: This pattern is painful to use and debug. It's much cleaner // to use pinning plus anonymous functions. Get rid of this // pattern! + console.error('The createCallback function is deprecated and will be removed in future versions. Please use alternativeFunction instead.'); var initialArgs = [], i; for ( i = 2; i < arguments.length; i++ ) { @@ -2326,43 +2358,18 @@ function OpenSeadragon( options ){ /** * Create an XHR object * @private - * @param {type} [local] If set to true, the XHR will be file: protocol - * compatible if possible (but may raise a warning in the browser). + * @param {type} [local] Deprecated. Ignored (IE/ActiveXObject file protocol no longer supported). * @returns {XMLHttpRequest} */ - createAjaxRequest: function( local ) { - // IE11 does not support window.ActiveXObject so we just try to - // create one to see if it is supported. - // See: http://msdn.microsoft.com/en-us/library/ie/dn423948%28v=vs.85%29.aspx - var supportActiveX; - try { - /* global ActiveXObject:true */ - supportActiveX = !!new ActiveXObject( "Microsoft.XMLHTTP" ); - } catch( e ) { - supportActiveX = false; - } - - if ( supportActiveX ) { - if ( window.XMLHttpRequest ) { - $.createAjaxRequest = function( local ) { - if ( local ) { - return new ActiveXObject( "Microsoft.XMLHTTP" ); - } - return new XMLHttpRequest(); - }; - } else { - $.createAjaxRequest = function() { - return new ActiveXObject( "Microsoft.XMLHTTP" ); - }; - } - } else if ( window.XMLHttpRequest ) { + createAjaxRequest: function() { + if ( window.XMLHttpRequest ) { $.createAjaxRequest = function() { return new XMLHttpRequest(); }; + return new XMLHttpRequest(); } else { throw new Error( "Browser doesn't support XMLHttpRequest." ); } - return $.createAjaxRequest( local ); }, /** @@ -2398,7 +2405,7 @@ function OpenSeadragon( options ){ } var protocol = $.getUrlProtocol( url ); - var request = $.createAjaxRequest( protocol === "file:" ); + var request = $.createAjaxRequest(); if ( !$.isFunction( onSuccess ) ) { throw new Error( "makeAjaxRequest requires a success callback" ); @@ -2567,17 +2574,6 @@ function OpenSeadragon( options ){ return xmlDoc; }; - } else if ( window.ActiveXObject ) { - - $.parseXml = function( string ) { - var xmlDoc = null; - - xmlDoc = new ActiveXObject( "Microsoft.XMLDOM" ); - xmlDoc.async = false; - xmlDoc.loadXML( string ); - return xmlDoc; - }; - } else { throw new Error( "Browser doesn't support XML DOM." ); } @@ -2614,18 +2610,20 @@ function OpenSeadragon( options ){ * Preexisting formats that are not being updated are left unchanged. * By default, the defined formats are *
{
+         *      avif: true,
          *      bmp:  false,
          *      jpeg: true,
          *      jpg:  true,
          *      png:  true,
          *      tif:  false,
-         *      wdp:  false
+         *      wdp:  false,
+         *      webp: true
          * }
          * 
* @function * @example - * // sets webp as supported and png as unsupported - * setImageFormatsSupported({webp: true, png: false}); + * // sets bmp as supported and png as unsupported + * setImageFormatsSupported({bmp: true, png: false}); * @param {Object} formats An object containing format extensions as * keys and booleans as values. */ @@ -2680,12 +2678,14 @@ function OpenSeadragon( options ){ var FILEFORMATS = { + avif: true, bmp: false, jpeg: true, jpg: true, png: true, tif: false, - wdp: false + wdp: false, + webp: true }, URLPARAMS = {}; @@ -2700,6 +2700,10 @@ function OpenSeadragon( options ){ //console.error( 'appVersion: ' + navigator.appVersion ); //console.error( 'userAgent: ' + navigator.userAgent ); + //TODO navigator.appName is deprecated. Should be 'Netscape' for all browsers + // but could be dropped at any time + // See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/appName + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent switch( navigator.appName ){ case "Microsoft Internet Explorer": if( !!window.attachEvent && @@ -2785,8 +2789,8 @@ function OpenSeadragon( options ){ //determine if this browser supports element.style.opacity $.Browser.opacity = true; - if ( $.Browser.vendor === $.BROWSERS.IE && $.Browser.version < 11 ) { - $.console.error('Internet Explorer versions < 11 are not supported by OpenSeadragon'); + if ( $.Browser.vendor === $.BROWSERS.IE ) { + $.console.error('Internet Explorer is not supported by OpenSeadragon'); } })(); @@ -2912,11 +2916,221 @@ function OpenSeadragon( options ){ return OpenSeadragon; })); +/* + * OpenSeadragon - Mat3 + * + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + + +/* + * Portions of this source file are taken from WegGL Fundamentals: + * + * Copyright 2012, Gregg Tavares. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Gregg Tavares. nor the names of his + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + + + + +(function( $ ){ + +// Modified from https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html + +/** + * + * + * @class Mat3 + * @classdesc A left-to-right matrix representation, useful for affine transforms for + * positioning tiles for drawing + * + * @memberof OpenSeadragon + * + * @param {Array} [values] - Initial values for the matrix + * + **/ +class Mat3{ + constructor(values){ + if(!values) { + values = [ + 0, 0, 0, + 0, 0, 0, + 0, 0, 0 + ]; + } + this.values = values; + } + + /** + * @function makeIdentity + * @memberof OpenSeadragon.Mat3 + * @static + * @returns {OpenSeadragon.Mat3} an identity matrix + */ + static makeIdentity(){ + return new Mat3([ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ]); + } + + /** + * @function makeTranslation + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} tx The x value of the translation + * @param {Number} ty The y value of the translation + * @returns {OpenSeadragon.Mat3} A translation matrix + */ + static makeTranslation(tx, ty) { + return new Mat3([ + 1, 0, 0, + 0, 1, 0, + tx, ty, 1, + ]); + } + + /** + * @function makeRotation + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} angleInRadians The desired rotation angle, in radians + * @returns {OpenSeadragon.Mat3} A rotation matrix + */ + static makeRotation(angleInRadians) { + var c = Math.cos(angleInRadians); + var s = Math.sin(angleInRadians); + return new Mat3([ + c, -s, 0, + s, c, 0, + 0, 0, 1, + ]); + } + + /** + * @function makeScaling + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} sx The x value of the scaling + * @param {Number} sy The y value of the scaling + * @returns {OpenSeadragon.Mat3} A scaling matrix + */ + static makeScaling(sx, sy) { + return new Mat3([ + sx, 0, 0, + 0, sy, 0, + 0, 0, 1, + ]); + } + + /** + * @alias multiply + * @memberof! OpenSeadragon.Mat3 + * @param {OpenSeadragon.Mat3} other the matrix to multiply with + * @returns {OpenSeadragon.Mat3} The result of matrix multiplication + */ + multiply(other) { + let a = this.values; + let b = other.values; + + var a00 = a[0 * 3 + 0]; + var a01 = a[0 * 3 + 1]; + var a02 = a[0 * 3 + 2]; + var a10 = a[1 * 3 + 0]; + var a11 = a[1 * 3 + 1]; + var a12 = a[1 * 3 + 2]; + var a20 = a[2 * 3 + 0]; + var a21 = a[2 * 3 + 1]; + var a22 = a[2 * 3 + 2]; + var b00 = b[0 * 3 + 0]; + var b01 = b[0 * 3 + 1]; + var b02 = b[0 * 3 + 2]; + var b10 = b[1 * 3 + 0]; + var b11 = b[1 * 3 + 1]; + var b12 = b[1 * 3 + 2]; + var b20 = b[2 * 3 + 0]; + var b21 = b[2 * 3 + 1]; + var b22 = b[2 * 3 + 2]; + return new Mat3([ + b00 * a00 + b01 * a10 + b02 * a20, + b00 * a01 + b01 * a11 + b02 * a21, + b00 * a02 + b01 * a12 + b02 * a22, + b10 * a00 + b11 * a10 + b12 * a20, + b10 * a01 + b11 * a11 + b12 * a21, + b10 * a02 + b11 * a12 + b12 * a22, + b20 * a00 + b21 * a10 + b22 * a20, + b20 * a01 + b21 * a11 + b22 * a21, + b20 * a02 + b21 * a12 + b22 * a22, + ]); + } +} + + +$.Mat3 = Mat3; + +}( OpenSeadragon )); + /* * OpenSeadragon - full-screen support functions * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -2981,10 +3195,14 @@ function OpenSeadragon( options ){ return document.fullscreenElement; }; fullScreenApi.requestFullScreen = function( element ) { - return element.requestFullscreen(); + return element.requestFullscreen().catch(function (msg) { + $.console.error('Fullscreen request failed: ', msg); + }); }; fullScreenApi.exitFullScreen = function() { - document.exitFullscreen(); + document.exitFullscreen().catch(function (msg) { + $.console.error('Error while exiting fullscreen: ', msg); + }); }; fullScreenApi.fullScreenEventName = "fullscreenchange"; fullScreenApi.fullScreenErrorEventName = "fullscreenerror"; @@ -3062,7 +3280,7 @@ function OpenSeadragon( options ){ * OpenSeadragon - EventSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -3111,6 +3329,7 @@ function OpenSeadragon( options ){ */ $.EventSource = function() { this.events = {}; + this._rejectedEventList = {}; }; /** @lends OpenSeadragon.EventSource.prototype */ @@ -3128,6 +3347,7 @@ $.EventSource.prototype = { * @param {Number} [times=1] - The number of times to handle the event * before removing it. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. + * @returns {Boolean} - True if the handler was added, false if it was rejected */ addOnceHandler: function(eventName, handler, userData, times, priority) { var self = this; @@ -3140,7 +3360,7 @@ $.EventSource.prototype = { } return handler(event); }; - this.addHandler(eventName, onceHandler, userData, priority); + return this.addHandler(eventName, onceHandler, userData, priority); }, /** @@ -3150,8 +3370,15 @@ $.EventSource.prototype = { * @param {OpenSeadragon.EventHandler} handler - Function to call when event is triggered. * @param {Object} [userData=null] - Arbitrary object to be passed unchanged to the handler. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. + * @returns {Boolean} - True if the handler was added, false if it was rejected */ addHandler: function ( eventName, handler, userData, priority ) { + + if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ + $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); + return false; + } + var events = this.events[ eventName ]; if ( !events ) { this.events[ eventName ] = events = []; @@ -3166,6 +3393,7 @@ $.EventSource.prototype = { index--; } } + return true; }, /** @@ -3251,17 +3479,45 @@ $.EventSource.prototype = { * @function * @param {String} eventName - Name of event to register. * @param {Object} eventArgs - Event-specific data. + * @returns {Boolean} True if the event was fired, false if it was rejected because of rejectEventHandler(eventName) */ raiseEvent: function( eventName, eventArgs ) { //uncomment if you want to get a log of all events //$.console.log( eventName ); + if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ + $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); + return false; + } + var handler = this.getHandler( eventName ); if ( handler ) { - return handler( this, eventArgs || {} ); + handler( this, eventArgs || {} ); } - return undefined; + return true; + }, + + /** + * Set an event name as being disabled, and provide an optional error message + * to be printed to the console + * @param {String} eventName - Name of the event + * @param {String} [errorMessage] - Optional string to print to the console + * @private + */ + rejectEventHandler(eventName, errorMessage = ''){ + this._rejectedEventList[eventName] = errorMessage; + }, + + /** + * Explicitly allow an event handler to be added for this event type, undoing + * the effects of rejectEventHandler + * @param {String} eventName - Name of the event + * @private + */ + allowEventHandler(eventName){ + delete this._rejectedEventList[eventName]; } + }; }( OpenSeadragon )); @@ -3270,7 +3526,7 @@ $.EventSource.prototype = { * OpenSeadragon - MouseTracker * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -3325,16 +3581,16 @@ $.EventSource.prototype = { * @param {Boolean} [options.startDisabled=false] * If true, event tracking on the element will not start until * {@link OpenSeadragon.MouseTracker.setTracking|setTracking} is called. - * @param {Number} options.clickTimeThreshold + * @param {Number} [options.clickTimeThreshold=300] * The number of milliseconds within which a pointer down-up event combination * will be treated as a click gesture. - * @param {Number} options.clickDistThreshold + * @param {Number} [options.clickDistThreshold=5] * The maximum distance allowed between a pointer down event and a pointer up event * to be treated as a click gesture. - * @param {Number} options.dblClickTimeThreshold + * @param {Number} [options.dblClickTimeThreshold=300] * The number of milliseconds within which two pointer down-up event combinations * will be treated as a double-click gesture. - * @param {Number} options.dblClickDistThreshold + * @param {Number} [options.dblClickDistThreshold=20] * The maximum distance allowed between two pointer click events * to be treated as a click gesture. * @param {Number} [options.stopDelay=50] @@ -3625,7 +3881,7 @@ $.EventSource.prototype = { getActivePointersListByType: function ( type ) { var delegate = THIS[ this.hash ], i, - len = delegate.activePointersLists.length, + len = delegate ? delegate.activePointersLists.length : 0, list; for ( i = 0; i < len; i++ ) { @@ -3635,7 +3891,9 @@ $.EventSource.prototype = { } list = new $.MouseTracker.GesturePointList( type ); - delegate.activePointersLists.push( list ); + if(delegate){ + delegate.activePointersLists.push( list ); + } return list; }, @@ -4382,10 +4640,9 @@ $.EventSource.prototype = { /** * Detect available mouse wheel event name. */ - $.MouseTracker.wheelEventName = ( $.Browser.vendor === $.BROWSERS.IE && $.Browser.version > 8 ) || - ( 'onwheel' in document.createElement( 'div' ) ) ? 'wheel' : // Modern browsers support 'wheel' - document.onmousewheel !== undefined ? 'mousewheel' : // Webkit and IE support at least 'mousewheel' - 'DOMMouseScroll'; // Assume old Firefox + $.MouseTracker.wheelEventName = ( 'onwheel' in document.createElement( 'div' ) ) ? 'wheel' : // Modern browsers support 'wheel' + document.onmousewheel !== undefined ? 'mousewheel' : // Webkit (and unsupported IE) support at least 'mousewheel' + 'DOMMouseScroll'; // Assume old Firefox (deprecated) /** * Detect browser pointer device event model(s) and build appropriate list of events to subscribe to. @@ -4398,7 +4655,7 @@ $.EventSource.prototype = { } if ( window.PointerEvent ) { - // IE11 and other W3C Pointer Event implementations (see http://www.w3.org/TR/pointerevents) + // W3C Pointer Event implementations (see http://www.w3.org/TR/pointerevents) $.MouseTracker.havePointerEvents = true; $.MouseTracker.subscribeEvents.push( "pointerenter", "pointerleave", "pointerover", "pointerout", "pointerdown", "pointerup", "pointermove", "pointercancel" ); // Pointer events capture support @@ -4937,7 +5194,6 @@ $.EventSource.prototype = { /** * Gets a W3C Pointer Events model compatible pointer type string from a DOM pointer event. - * IE10 used a long integer value, but the W3C specification (and IE11+) use a string "mouse", "touch", "pen", etc. * * Note: Called for both pointer events and legacy mouse events * ($.MouseTracker.havePointerEvents determines which) @@ -4945,14 +5201,7 @@ $.EventSource.prototype = { * @inner */ function getPointerType( event ) { - if ( $.MouseTracker.havePointerEvents ) { - // Note: IE pointer events bug - sends invalid pointerType on lostpointercapture events - // and possibly other events. We rely on sane, valid property values in DOM events, so for - // IE, when the pointerType is missing, we'll default to 'mouse'...should be right most of the time - return event.pointerType || (( $.Browser.vendor === $.BROWSERS.IE ) ? 'mouse' : ''); - } else { - return 'mouse'; - } + return $.MouseTracker.havePointerEvents && event.pointerType ? event.pointerType : 'mouse'; } @@ -5338,7 +5587,7 @@ $.EventSource.prototype = { // y-index scrolling. // event.deltaMode: 0=pixel, 1=line, 2=page // TODO: Deltas in pixel mode should be accumulated then a scroll value computed after $.DEFAULT_SETTINGS.pixelsPerWheelLine threshold reached - nDelta = event.deltaY < 0 ? 1 : -1; + nDelta = event.deltaY ? (event.deltaY < 0 ? 1 : -1) : 0; eventInfo = { originalEvent: event, @@ -5820,15 +6069,14 @@ $.EventSource.prototype = { }; // Most browsers implicitly capture touch pointer events - // Note no IE versions have element.hasPointerCapture() so no implicit - // pointer capture possible + // Note no IE versions (unsupported) have element.hasPointerCapture() so + // no implicit pointer capture possible // var implicitlyCaptured = ($.MouseTracker.havePointerEvents && // event.target.hasPointerCapture && // $.Browser.vendor !== $.BROWSERS.IE) ? // event.target.hasPointerCapture(event.pointerId) : false; var implicitlyCaptured = $.MouseTracker.havePointerEvents && - gPoint.type === 'touch' && - $.Browser.vendor !== $.BROWSERS.IE; + gPoint.type === 'touch'; //$.console.log('pointerdown ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : '')); @@ -7046,7 +7294,7 @@ $.EventSource.prototype = { * OpenSeadragon - Control * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -7238,11 +7486,7 @@ $.Control.prototype = { * @param {Number} opactiy - a value between 1 and 0 inclusively. */ setOpacity: function( opacity ) { - if ( this.element[ $.SIGNAL ] && $.Browser.vendor === $.BROWSERS.IE ) { - $.setElementOpacity( this.element, opacity, true ); - } else { - $.setElementOpacity( this.wrapper, opacity, true ); - } + $.setElementOpacity( this.wrapper, opacity, true ); } }; @@ -7252,7 +7496,7 @@ $.Control.prototype = { * OpenSeadragon - ControlDock * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -7309,7 +7553,9 @@ $.Control.prototype = { if( this.element ){ this.element = $.getElement( this.element ); this.element.appendChild( this.container ); - this.element.style.position = 'relative'; + if( $.getElementStyle(this.element).position === 'static' ){ + this.element.style.position = 'relative'; + } this.container.style.width = '100%'; this.container.style.height = '100%'; } @@ -7481,7 +7727,7 @@ $.Control.prototype = { /* * OpenSeadragon - Placement * - * Copyright (C) 2010-2016 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -7621,7 +7867,7 @@ $.Control.prototype = { * OpenSeadragon - Viewer * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -7708,6 +7954,21 @@ $.Viewer = function( options ) { delete options.config; } + // Move deprecated drawer options from the base options object into a sub-object + // This is an array to make it easy to add additional properties to convert to + // drawer options later if it makes sense to set at the drawer level rather than + // per tiled image (for example, subPixelRoundingForTransparency). + let drawerOptionList = [ + 'useCanvas', // deprecated + ]; + options.drawerOptions = Object.assign({}, + drawerOptionList.reduce((drawerOptions, option) => { + drawerOptions[option] = options[option]; + delete options[option]; + return drawerOptions; + }, {}), + options.drawerOptions); + //Public properties //Allow the options object to override global defaults $.extend( true, this, { @@ -7817,6 +8078,7 @@ $.Viewer = function( options ) { $.console.warn("Hash " + this.hash + " has already been used."); } + //Private state properties THIS[ this.hash ] = { fsBoundsDelta: new $.Point( 1, 1 ), @@ -8002,24 +8264,25 @@ $.Viewer = function( options ) { // Create the viewport this.viewport = new $.Viewport({ - containerSize: THIS[ this.hash ].prevContainerSize, - springStiffness: this.springStiffness, - animationTime: this.animationTime, - minZoomImageRatio: this.minZoomImageRatio, - maxZoomPixelRatio: this.maxZoomPixelRatio, - visibilityRatio: this.visibilityRatio, - wrapHorizontal: this.wrapHorizontal, - wrapVertical: this.wrapVertical, - defaultZoomLevel: this.defaultZoomLevel, - minZoomLevel: this.minZoomLevel, - maxZoomLevel: this.maxZoomLevel, - viewer: this, - degrees: this.degrees, - flipped: this.flipped, - navigatorRotate: this.navigatorRotate, - homeFillsViewer: this.homeFillsViewer, - margins: this.viewportMargins, - silenceMultiImageWarnings: this.silenceMultiImageWarnings + containerSize: THIS[ this.hash ].prevContainerSize, + springStiffness: this.springStiffness, + animationTime: this.animationTime, + minZoomImageRatio: this.minZoomImageRatio, + maxZoomPixelRatio: this.maxZoomPixelRatio, + visibilityRatio: this.visibilityRatio, + wrapHorizontal: this.wrapHorizontal, + wrapVertical: this.wrapVertical, + defaultZoomLevel: this.defaultZoomLevel, + minZoomLevel: this.minZoomLevel, + maxZoomLevel: this.maxZoomLevel, + viewer: this, + degrees: this.degrees, + flipped: this.flipped, + overlayPreserveContentDirection: this.overlayPreserveContentDirection, + navigatorRotate: this.navigatorRotate, + homeFillsViewer: this.homeFillsViewer, + margins: this.viewportMargins, + silenceMultiImageWarnings: this.silenceMultiImageWarnings }); this.viewport._setContentBounds(this.world.getHomeBounds(), this.world.getContentFactor()); @@ -8037,13 +8300,41 @@ $.Viewer = function( options ) { maxImageCacheCount: this.maxImageCacheCount }); - // Create the drawer - this.drawer = new $.Drawer({ - viewer: this, - viewport: this.viewport, - element: this.canvas, - debugGridColor: this.debugGridColor - }); + //Create the drawer based on selected options + if (Object.prototype.hasOwnProperty.call(this.drawerOptions, 'useCanvas') ){ + $.console.error('useCanvas is deprecated, use the "drawer" option to indicate preferred drawer(s)'); + + // for backwards compatibility, use HTMLDrawer if useCanvas is defined and is falsey + if (!this.drawerOptions.useCanvas){ + this.drawer = $.HTMLDrawer; + } + + delete this.drawerOptions.useCanvas; + } + let drawerCandidates = Array.isArray(this.drawer) ? this.drawer : [this.drawer]; + if (drawerCandidates.length === 0){ + // if an empty array was passed in, throw a warning and use the defaults + // note: if the drawer option is not specified, the defaults will already be set so this won't apply + drawerCandidates = [$.DEFAULT_SETTINGS.drawer].flat(); // ensure it is a list + $.console.warn('No valid drawers were selected. Using the default value.'); + } + + + this.drawer = null; + for (const drawerCandidate of drawerCandidates){ + let success = this.requestDrawer(drawerCandidate, {mainDrawer: true, redrawImmediately: false}); + if(success){ + break; + } + } + + if (!this.drawer){ + $.console.error('No drawer could be created!'); + throw('Error with creating the selected drawer(s)'); + } + + // Pass the imageSmoothingEnabled option along to the drawer + this.drawer.setImageSmoothingEnabled(this.imageSmoothingEnabled); // Overlay container this.overlaysContainer = $.makeNeutralElement( "div" ); @@ -8089,6 +8380,10 @@ $.Viewer = function( options ) { displayRegionColor: this.navigatorDisplayRegionColor, crossOriginPolicy: this.crossOriginPolicy, animationTime: this.animationTime, + drawer: this.drawer.getType(), + loadTilesWithAjax: this.loadTilesWithAjax, + ajaxHeaders: this.ajaxHeaders, + ajaxWithCredentials: this.ajaxWithCredentials, }); } @@ -8115,11 +8410,6 @@ $.Viewer = function( options ) { beginControlsAutoHide( _this ); } ); - // Initial canvas options - if ( this.imageSmoothingEnabled !== undefined && !this.imageSmoothingEnabled){ - this.drawer.setImageSmoothingEnabled(this.imageSmoothingEnabled); - } - // Register the viewer $._viewers.set(this.element, this); }; @@ -8507,6 +8797,73 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, this.removeAllHandlers(); }, + /** + * Request a drawer for this viewer, as a supported string or drawer constructor. + * @param {String | OpenSeadragon.DrawerBase} drawerCandidate The type of drawer to try to construct. + * @param { Object } options + * @param { Boolean } [options.mainDrawer] Whether to use this as the viewer's main drawer. Default = true. + * @param { Boolean } [options.redrawImmediately] Whether to immediately draw a new frame. Only used if options.mainDrawer = true. Default = true. + * @param { Object } [options.drawerOptions] Options for this drawer. Defaults to viewer.drawerOptions. + * for this viewer type. See {@link OpenSeadragon.Options}. + * @returns {Object | Boolean} The drawer that was created, or false if the requested drawer is not supported + */ + requestDrawer(drawerCandidate, options){ + const defaultOpts = { + mainDrawer: true, + redrawImmediately: true, + drawerOptions: null + }; + options = $.extend(true, defaultOpts, options); + const mainDrawer = options.mainDrawer; + const redrawImmediately = options.redrawImmediately; + const drawerOptions = options.drawerOptions; + + const oldDrawer = this.drawer; + + let Drawer = null; + + //if the candidate inherits from a drawer base, use it + if (drawerCandidate && drawerCandidate.prototype instanceof $.DrawerBase) { + Drawer = drawerCandidate; + drawerCandidate = 'custom'; + } else if (typeof drawerCandidate === "string") { + Drawer = $.determineDrawer(drawerCandidate); + } + + if(!Drawer){ + $.console.warn('Unsupported drawer! Drawer must be an existing string type, or a class that extends OpenSeadragon.DrawerBase.'); + } + + // if the drawer is supported, create it and return true + if (Drawer && Drawer.isSupported()) { + + // first destroy the previous drawer + if(oldDrawer && mainDrawer){ + oldDrawer.destroy(); + } + + // create the new drawer + const newDrawer = new Drawer({ + viewer: this, + viewport: this.viewport, + element: this.canvas, + debugGridColor: this.debugGridColor, + options: drawerOptions || this.drawerOptions[drawerCandidate], + }); + + if(mainDrawer){ + this.drawer = newDrawer; + if(redrawImmediately){ + this.forceRedraw(); + } + } + + return newDrawer; + } + + return false; + }, + /** * @function * @returns {Boolean} @@ -8659,7 +9016,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * @returns {Boolean} */ isFullPage: function () { - return THIS[ this.hash ].fullPage; + return THIS[this.hash] && THIS[ this.hash ].fullPage; }, @@ -8706,7 +9063,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, return this; } - if ( fullPage ) { + if ( fullPage && this.element ) { this.elementSize = $.getElementSize( this.element ); this.pageScroll = $.getPageScroll(); @@ -9223,6 +9580,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, minZoomImageRatio: _this.minZoomImageRatio, wrapHorizontal: _this.wrapHorizontal, wrapVertical: _this.wrapVertical, + maxTilesPerFrame: _this.maxTilesPerFrame, immediateRender: _this.immediateRender, blendTime: _this.blendTime, alwaysBlend: _this.alwaysBlend, @@ -9709,7 +10067,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * viewport which the location coordinates will be treated as relative * to. * @param {function} [onDraw] - If supplied the callback is called when the overlay - * needs to be drawn. It it the responsibility of the callback to do any drawing/positioning. + * needs to be drawn. It is the responsibility of the callback to do any drawing/positioning. * It is passed position, size and element. * @returns {OpenSeadragon.Viewer} Chainable. * @fires OpenSeadragon.Viewer.event:add-overlay @@ -10021,7 +10379,6 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, width: this.referenceStripWidth, tileSources: this.tileSources, prefixUrl: this.prefixUrl, - useCanvas: this.useCanvas, viewer: this }); @@ -10050,8 +10407,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, }, /** - * Update pixel density ratio, clears all tiles and triggers updates for - * all items if the ratio has changed. + * Update pixel density ratio and forces a resize operation. * @private */ _updatePixelDensityRatio: function() { @@ -10059,8 +10415,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, var currentPixelDensityRatio = $.getCurrentPixelDensityRatio(); if (previusPixelDensityRatio !== currentPixelDensityRatio) { $.pixelDensityRatio = currentPixelDensityRatio; - this.world.resetItems(); - this.forceRedraw(); + this.forceResize(); } }, @@ -10170,7 +10525,6 @@ function getTileSourceImplementation( viewer, tileSource, imgOptions, successCal ajaxHeaders: imgOptions.ajaxHeaders ? imgOptions.ajaxHeaders : viewer.ajaxHeaders, splitHashDataForPost: viewer.splitHashDataForPost, - useCanvas: viewer.useCanvas, success: function( event ) { successCallback( event.tileSource ); } @@ -10188,9 +10542,6 @@ function getTileSourceImplementation( viewer, tileSource, imgOptions, successCal if (tileSource.ajaxWithCredentials === undefined) { tileSource.ajaxWithCredentials = viewer.ajaxWithCredentials; } - if (tileSource.useCanvas === undefined) { - tileSource.useCanvas = viewer.useCanvas; - } if ( $.isFunction( tileSource.getTileUrl ) ) { //Custom tile source @@ -10793,10 +11144,11 @@ function onCanvasDragEnd( event ) { */ this.raiseEvent('canvas-drag-end', canvasDragEndEventArgs); - gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); + gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); if (!canvasDragEndEventArgs.preventDefaultAction && this.viewport) { if ( !THIS[ this.hash ].draggingToZoom && + gestureSettings.dragToPan && gestureSettings.flickEnabled && event.speed >= gestureSettings.flickMinSpeed) { var amplitudeX = 0; @@ -11323,7 +11675,7 @@ function updateOnce( viewer ) { var viewportChange = viewer.viewport.update(); - var animated = viewer.world.update() || viewportChange; + var animated = viewer.world.update(viewportChange) || viewportChange; if (viewportChange) { /** @@ -11413,7 +11765,6 @@ function updateOnce( viewer ) { function drawWorld( viewer ) { viewer.imageLoader.clear(); - viewer.drawer.clear(); viewer.world.draw(); /** @@ -11567,13 +11918,31 @@ function onFlip() { this.viewport.toggleFlip(); } +/** + * Find drawer + */ +$.determineDrawer = function( id ){ + for (let property in OpenSeadragon) { + const drawer = OpenSeadragon[ property ], + proto = drawer.prototype; + if( proto && + proto instanceof OpenSeadragon.DrawerBase && + $.isFunction( proto.getType ) && + proto.getType.call( drawer ) === id + ){ + return drawer; + } + } + return null; +}; + }( OpenSeadragon )); /* * OpenSeadragon - Navigator * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -11741,9 +12110,6 @@ $.Navigator = function( options ){ style.border = borderWidth + 'px solid ' + options.displayRegionColor; style.margin = '0px'; style.padding = '0px'; - //TODO: IE doesn't like this property being set - //try{ style.outline = '2px auto #909'; }catch(e){/*ignore*/} - style.background = 'transparent'; // We use square bracket notation on the statement below, because float is a keyword. @@ -11752,7 +12118,6 @@ $.Navigator = function( options ){ style['float'] = 'left'; //Webkit style.cssFloat = 'left'; //Firefox - style.styleFloat = 'left'; //IE style.zIndex = 999999999; style.cursor = 'default'; style.boxSizing = 'content-box'; @@ -11881,8 +12246,9 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* this.viewport.resize( containerSize, true ); this.viewport.goHome(true); this.oldContainerSize = containerSize; - this.drawer.clear(); + this.world.update(); this.world.draw(); + this.update(this.viewer.viewport); } } }, @@ -11921,7 +12287,6 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* }, setDisplayTransform: function(rule) { - setElementTransform(this.displayRegion, rule); setElementTransform(this.canvas, rule); setElementTransform(this.element, rule); }, @@ -11929,7 +12294,7 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* /** * Used to update the navigator minimap's viewport rectangle when a change in the viewer's viewport occurs. * @function - * @param {OpenSeadragon.Viewport} The viewport this navigator is tracking. + * @param {OpenSeadragon.Viewport} [viewport] The viewport to display. Default: the viewport this navigator is tracking. */ update: function( viewport ) { @@ -11940,6 +12305,10 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* topleft, bottomright; + if(!viewport){ + viewport = this.viewer.viewport; + } + viewerSize = $.getElementSize( this.viewer.element ); if ( this._resizeWithViewer && viewerSize.x && viewerSize.y && !viewerSize.equals( this.oldViewerSize ) ) { this.oldViewerSize = viewerSize; @@ -12246,7 +12615,7 @@ function setElementTransform( element, rule ) { * OpenSeadragon - getString/setString * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -12368,7 +12737,7 @@ $.extend( $, /** @lends OpenSeadragon */{ * OpenSeadragon - Point * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -12614,7 +12983,7 @@ $.Point.prototype = { * OpenSeadragon - TileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -12982,6 +13351,7 @@ $.TileSource.prototype = { point.y >= 0 && point.y <= 1 / this.aspectRatio; $.console.assert(validPoint, "[TileSource.getTileAtPoint] must be called with a valid point."); + var widthScaled = this.dimensions.x * this.getLevelScale(level); var pixelX = point.x * widthScaled; var pixelY = point.y * widthScaled; @@ -13180,13 +13550,13 @@ $.TileSource.prototype = { }, /** - * Responsible determining if a the particular TileSource supports the + * Responsible for determining if the particular TileSource supports the * data format ( and allowed to apply logic against the url the data was * loaded from, if any ). Overriding implementations are expected to do * something smart with data and / or url to determine support. Also - * understand that iteration order of TileSources is not guarunteed so + * understand that iteration order of TileSources is not guaranteed so * please make sure your data or url is expressive enough to ensure a simple - * and sufficient mechanisim for clear determination. + * and sufficient mechanism for clear determination. * @function * @param {String|Object|Array|Document} data * @param {String} url - the url the data was loaded @@ -13387,7 +13757,7 @@ $.TileSource.prototype = { }; // Load the tile with an AJAX request if the loadWithAjax option is - // set. Otherwise load the image by setting the source proprety of the image object. + // set. Otherwise load the image by setting the source property of the image object. if (context.loadWithAjax) { dataStore.request = $.makeAjaxRequest({ url: context.src, @@ -13604,7 +13974,7 @@ $.TileSource.determineType = function( tileSource, data, url ){ * OpenSeadragon - DziTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -13983,7 +14353,7 @@ function configureFromObject( tileSource, configuration ){ * OpenSeadragon - IIIFTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -14126,7 +14496,7 @@ $.IIIFTileSource = function( options ){ if( this.sizes ) { var sizeLength = this.sizes.length; if ( (sizeLength === options.maxLevel) || (sizeLength === options.maxLevel + 1) ) { - this.levelSizes = this.sizes; + this.levelSizes = this.sizes.slice().sort(( size1, size2 ) => size1.width - size2.width); // Need to take into account that the list may or may not include the full resolution size if( sizeLength === options.maxLevel ) { this.levelSizes.push( {width: this.width, height: this.height} ); @@ -14599,7 +14969,7 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea * OpenSeadragon - OsmTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -14746,7 +15116,7 @@ $.extend( $.OsmTileSource.prototype, $.TileSource.prototype, /** @lends OpenSead * OpenSeadragon - TmsTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -15036,7 +15406,7 @@ $.extend( $.TmsTileSource.prototype, $.TileSource.prototype, /** @lends OpenSead * OpenSeadragon - LegacyTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -15328,7 +15698,7 @@ function configureFromObject( tileSource, configuration ){ * OpenSeadragon - ImageTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -15368,8 +15738,8 @@ function configureFromObject( tileSource, configuration ){ * 1. viewer.open({type: 'image', url: fooUrl}); * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl})); * - * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and - * useCanvas options are inherited from the viewer if they are not + * With the first syntax, the crossOriginPolicy and ajaxWithCredentials + * options are inherited from the viewer if they are not * specified directly in the options object. * * @memberof OpenSeadragon @@ -15384,16 +15754,13 @@ function configureFromObject( tileSource, configuration ){ * domains. * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set * the withCredentials XHR flag for AJAX requests (when loading tile sources). - * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use - * of the canvas API. */ $.ImageTileSource = function (options) { options = $.extend({ buildPyramid: true, crossOriginPolicy: false, - ajaxWithCredentials: false, - useCanvas: true + ajaxWithCredentials: false }, options); $.TileSource.apply(this, [options]); @@ -15524,9 +15891,11 @@ function configureFromObject( tileSource, configuration ){ /** * Destroys ImageTileSource * @function + * @param {OpenSeadragon.Viewer} viewer the viewer that is calling + * destroy on the ImageTileSource */ - destroy: function () { - this._freeupCanvasMemory(); + destroy: function (viewer) { + this._freeupCanvasMemory(viewer); }, // private @@ -15540,7 +15909,7 @@ function configureFromObject( tileSource, configuration ){ height: this._image.naturalHeight }]; - if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) { + if (!this.buildPyramid || !$.supportsCanvas) { // We don't need the image anymore. Allows it to be GC. delete this._image; return levels; @@ -15596,11 +15965,27 @@ function configureFromObject( tileSource, configuration ){ * and Safari keeps canvas until its height and width will be set to 0). * @function */ - _freeupCanvasMemory: function () { + _freeupCanvasMemory: function (viewer) { for (var i = 0; i < this.levels.length; i++) { if(this.levels[i].context2D){ this.levels[i].context2D.canvas.height = 0; this.levels[i].context2D.canvas.width = 0; + + if(viewer){ + /** + * Triggered when an image has just been unloaded + * + * @event image-unloaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded + * @private + */ + viewer.raiseEvent("image-unloaded", { + context2D: this.levels[i].context2D + }); + } + } } }, @@ -15612,7 +15997,7 @@ function configureFromObject( tileSource, configuration ){ * OpenSeadragon - TileSourceCollection * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -15655,7 +16040,7 @@ $.TileSourceCollection = function(tileSize, tileSources, rows, layout) { * OpenSeadragon - Button * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -15820,13 +16205,6 @@ $.Button = function( options ) { this.imgDown.style.visibility = "hidden"; - if ($.Browser.vendor === $.BROWSERS.FIREFOX && $.Browser.version < 3) { - this.imgGroup.style.top = - this.imgHover.style.top = - this.imgDown.style.top = - ""; - } - this.element.appendChild( this.imgRest ); this.element.appendChild( this.imgGroup ); this.element.appendChild( this.imgHover ); @@ -16200,7 +16578,7 @@ function outTo( button, newState ) { * OpenSeadragon - ButtonGroup * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -16359,7 +16737,7 @@ $.ButtonGroup.prototype = { * OpenSeadragon - Rect * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -16923,7 +17301,7 @@ $.Rect.prototype = { * OpenSeadragon - ReferenceStrip * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -16967,7 +17345,7 @@ var THIS = {}; * * This idea is a reexpression of the idea of dzi collections * which allows a clearer algorithm to reuse the tile sources already - * supported by OpenSeadragon, in heterogenious or homogenious + * supported by OpenSeadragon, in heterogeneous or homogeneous * sequences just like mixed groups already supported by the viewer * for the purpose of image sequnces. * @@ -17114,7 +17492,6 @@ $.ReferenceStrip = function ( options ) { element.style.display = 'inline'; element.style['float'] = 'left'; //Webkit element.style.cssFloat = 'left'; //Firefox - element.style.styleFloat = 'left'; //IE element.style.padding = '2px'; $.setElementTouchActionNone( element ); $.setElementPointerEventsNone( element ); @@ -17376,7 +17753,7 @@ function loadPanels( strip, viewerSize, scroll ) { animationTime: 0, loadTilesWithAjax: strip.viewer.loadTilesWithAjax, ajaxHeaders: strip.viewer.ajaxHeaders, - useCanvas: strip.useCanvas + drawer: 'canvas', //always use canvas for the reference strip } ); // Allow pointer events to pass through miniViewer's canvas/container // elements so implicit pointer capture works on touch devices @@ -17534,7 +17911,7 @@ function onKeyPress( event ) { * OpenSeadragon - DisplayRect * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -17606,7 +17983,7 @@ $.extend( $.DisplayRect.prototype, $.Rect.prototype ); * OpenSeadragon - Spring * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -17810,12 +18187,13 @@ $.Spring.prototype = { /** * @function - * @returns true if the value got updated, false otherwise + * @returns true if the spring is still updating its value, false if it is + * already at the target value. */ update: function() { this.current.time = $.now(); - var startValue, targetValue; + let startValue, targetValue; if (this._exponential) { startValue = this.start._logValue; targetValue = this.target._logValue; @@ -17824,24 +18202,25 @@ $.Spring.prototype = { targetValue = this.target.value; } - var currentValue = (this.current.time >= this.target.time) ? - targetValue : - startValue + - ( targetValue - startValue ) * - transform( - this.springStiffness, - ( this.current.time - this.start.time ) / - ( this.target.time - this.start.time ) - ); - - var oldValue = this.current.value; - if (this._exponential) { - this.current.value = Math.exp(currentValue); + if(this.current.time >= this.target.time){ + this.current.value = this.target.value; } else { - this.current.value = currentValue; + let currentValue = startValue + + ( targetValue - startValue ) * + transform( + this.springStiffness, + ( this.current.time - this.start.time ) / + ( this.target.time - this.start.time ) + ); + + if (this._exponential) { + this.current.value = Math.exp(currentValue); + } else { + this.current.value = currentValue; + } } - return oldValue !== this.current.value; + return this.current.value !== this.target.value; }, /** @@ -17868,7 +18247,7 @@ function transform( stiffness, x ) { * OpenSeadragon - ImageLoader * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -17903,6 +18282,8 @@ function transform( stiffness, x ) { /** * @class ImageJob * @classdesc Handles downloading of a single image. + * + * @memberof OpenSeadragon * @param {Object} options - Options for this ImageJob. * @param {String} [options.src] - URL of image to download. * @param {Tile} [options.tile] - Tile that belongs the data to. @@ -17953,6 +18334,7 @@ $.ImageJob.prototype = { /** * Starts the image job. * @method + * @memberof OpenSeadragon.ImageJob# */ start: function() { this.tries++; @@ -17979,6 +18361,7 @@ $.ImageJob.prototype = { * @param {*} data data that has been downloaded * @param {XMLHttpRequest} request reference to the request if used * @param {string} errorMessage description upon failure + * @memberof OpenSeadragon.ImageJob# */ finish: function(data, request, errorMessage ) { this.data = data; @@ -18132,7 +18515,7 @@ function completeJob(loader, job, callback) { * OpenSeadragon - Tile * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -18211,6 +18594,12 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.bounds = bounds; + /** + * Where this tile fits, in normalized coordinates, after positioning + * @member {OpenSeadragon.Rect} positionedBounds + * @memberof OpenSeadragon.Tile# + */ + this.positionedBounds = new OpenSeadragon.Rect(bounds.x, bounds.y, bounds.width, bounds.height); /** * The portion of the tile to use as the source of the drawing operation, in pixels. Note that * this only works when drawing with canvas; when drawing with HTML the entire tile is always used. @@ -18404,64 +18793,6 @@ $.Tile.prototype = { return !!this.context2D || this.getUrl().match('.png'); }, - /** - * Renders the tile in an html container. - * @function - * @param {Element} container - */ - drawHTML: function( container ) { - if (!this.cacheImageRecord) { - $.console.warn( - '[Tile.drawHTML] attempting to draw tile %s when it\'s not cached', - this.toString()); - return; - } - - if ( !this.loaded ) { - $.console.warn( - "Attempting to draw tile %s when it's not yet loaded.", - this.toString() - ); - return; - } - - //EXPERIMENTAL - trying to figure out how to scale the container - // content during animation of the container size. - - if ( !this.element ) { - var image = this.getImage(); - if (!image) { - return; - } - - this.element = $.makeNeutralElement( "div" ); - this.imgElement = image.cloneNode(); - this.imgElement.style.msInterpolationMode = "nearest-neighbor"; - this.imgElement.style.width = "100%"; - this.imgElement.style.height = "100%"; - - this.style = this.element.style; - this.style.position = "absolute"; - } - if ( this.element.parentNode !== container ) { - container.appendChild( this.element ); - } - if ( this.imgElement.parentNode !== this.element ) { - this.element.appendChild( this.imgElement ); - } - - this.style.top = this.position.y + "px"; - this.style.left = this.position.x + "px"; - this.style.height = this.size.y + "px"; - this.style.width = this.size.x + "px"; - - if (this.flipped) { - this.style.transform = "scaleX(-1)"; - } - - $.setElementOpacity( this.element, this.opacity ); - }, - /** * The Image object for this tile. * @member {Object} image @@ -18512,114 +18843,7 @@ $.Tile.prototype = { * @returns {CanvasRenderingContext2D} */ getCanvasContext: function() { - return this.context2D || this.cacheImageRecord.getRenderedContext(); - }, - - /** - * Renders the tile in a canvas-based context. - * @function - * @param {Canvas} context - * @param {Function} drawingHandler - Method for firing the drawing event. - * drawingHandler({context, tile, rendered}) - * where rendered is the context with the pre-drawn image. - * @param {Number} [scale=1] - Apply a scale to position and size - * @param {OpenSeadragon.Point} [translate] - A translation vector - * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round - * position and size of tiles supporting alpha channel in non-transparency - * context. - * @param {OpenSeadragon.TileSource} source - The source specification of the tile. - */ - drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) { - - var position = this.position.times($.pixelDensityRatio), - size = this.size.times($.pixelDensityRatio), - rendered; - - if (!this.context2D && !this.cacheImageRecord) { - $.console.warn( - '[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached', - this.toString()); - return; - } - - rendered = this.getCanvasContext(); - - if ( !this.loaded || !rendered ){ - $.console.warn( - "Attempting to draw tile %s when it's not yet loaded.", - this.toString() - ); - - return; - } - - context.save(); - context.globalAlpha = this.opacity; - - if (typeof scale === 'number' && scale !== 1) { - // draw tile at a different scale - position = position.times(scale); - size = size.times(scale); - } - - if (translate instanceof $.Point) { - // shift tile position slightly - position = position.plus(translate); - } - - //if we are supposed to be rendering fully opaque rectangle, - //ie its done fading or fading is turned off, and if we are drawing - //an image with an alpha channel, then the only way - //to avoid seeing the tile underneath is to clear the rectangle - if (context.globalAlpha === 1 && this.hasTransparency) { - if (shouldRoundPositionAndSize) { - // Round to the nearest whole pixel so we don't get seams from overlap. - position.x = Math.round(position.x); - position.y = Math.round(position.y); - size.x = Math.round(size.x); - size.y = Math.round(size.y); - } - - //clearing only the inside of the rectangle occupied - //by the png prevents edge flikering - context.clearRect( - position.x, - position.y, - size.x, - size.y - ); - } - - // This gives the application a chance to make image manipulation - // changes as we are rendering the image - drawingHandler({context: context, tile: this, rendered: rendered}); - - var sourceWidth, sourceHeight; - if (this.sourceBounds) { - sourceWidth = Math.min(this.sourceBounds.width, rendered.canvas.width); - sourceHeight = Math.min(this.sourceBounds.height, rendered.canvas.height); - } else { - sourceWidth = rendered.canvas.width; - sourceHeight = rendered.canvas.height; - } - - context.translate(position.x + size.x / 2, 0); - if (this.flipped) { - context.scale(-1, 1); - } - context.drawImage( - rendered.canvas, - 0, - 0, - sourceWidth, - sourceHeight, - -size.x / 2, - position.y, - size.x, - size.y - ); - - context.restore(); + return this.context2D || (this.cacheImageRecord && this.cacheImageRecord.getRenderedContext()); }, /** @@ -18691,7 +18915,7 @@ $.Tile.prototype = { * OpenSeadragon - Overlay * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -18817,8 +19041,17 @@ $.Tile.prototype = { }; } + this.elementWrapper = document.createElement('div'); this.element = options.element; - this.style = options.element.style; + this.elementWrapper.appendChild(this.element); + + if (this.element.id) { + this.elementWrapper.id = "overlay-wrapper-" + this.element.id; + } else { + this.elementWrapper.id = "overlay-wrapper"; + } + + this.style = this.elementWrapper.style; this._init(options); }; @@ -18885,7 +19118,7 @@ $.Tile.prototype = { * @function */ destroy: function() { - var element = this.element; + var element = this.elementWrapper; var style = this.style; if (element.parentNode) { @@ -18930,7 +19163,7 @@ $.Tile.prototype = { * @param {Element} container */ drawHTML: function(container, viewport) { - var element = this.element; + var element = this.elementWrapper; if (element.parentNode !== container) { //save the source parent for later if we need it element.prevElementParent = element.parentNode; @@ -18941,43 +19174,57 @@ $.Tile.prototype = { this.style.position = "absolute"; // this.size is used by overlays which don't get scaled in at // least one direction when this.checkResize is set to false. - this.size = $.getElementSize(element); + this.size = $.getElementSize(this.elementWrapper); } - var positionAndSize = this._getOverlayPositionAndSize(viewport); - var position = positionAndSize.position; var size = this.size = positionAndSize.size; - var rotate = positionAndSize.rotate; - + var outerScale = ""; + if (viewport.overlayPreserveContentDirection) { + outerScale = viewport.flipped ? " scaleX(-1)" : " scaleX(1)"; + } + var rotate = viewport.flipped ? -positionAndSize.rotate : positionAndSize.rotate; + var scale = viewport.flipped ? " scaleX(-1)" : ""; // call the onDraw callback if it exists to allow one to overwrite // the drawing/positioning/sizing of the overlay if (this.onDraw) { this.onDraw(position, size, this.element); } else { var style = this.style; + var innerStyle = this.element.style; + innerStyle.display = "block"; style.left = position.x + "px"; style.top = position.y + "px"; if (this.width !== null) { - style.width = size.x + "px"; + innerStyle.width = size.x + "px"; } if (this.height !== null) { - style.height = size.y + "px"; + innerStyle.height = size.y + "px"; } var transformOriginProp = $.getCssPropertyWithVendorPrefix( 'transformOrigin'); var transformProp = $.getCssPropertyWithVendorPrefix( 'transform'); if (transformOriginProp && transformProp) { - if (rotate) { + if (rotate && !viewport.flipped) { + innerStyle[transformProp] = ""; style[transformOriginProp] = this._getTransformOrigin(); style[transformProp] = "rotate(" + rotate + "deg)"; + } else if (!rotate && viewport.flipped) { + innerStyle[transformProp] = outerScale; + style[transformOriginProp] = this._getTransformOrigin(); + style[transformProp] = scale; + } else if (rotate && viewport.flipped){ + innerStyle[transformProp] = outerScale; + style[transformOriginProp] = this._getTransformOrigin(); + style[transformProp] = "rotate(" + rotate + "deg)" + scale; } else { + innerStyle[transformProp] = ""; style[transformOriginProp] = ""; style[transformProp] = ""; } } - style.display = 'block'; + style.display = 'flex'; } }, @@ -19003,6 +19250,9 @@ $.Tile.prototype = { } } + if (viewport.flipped) { + position.x = (viewport.getContainerSize().x - position.x); + } return { position: position, size: size, @@ -19026,7 +19276,7 @@ $.Tile.prototype = { } if (this.checkResize && (this.width === null || this.height === null)) { - var eltSize = this.size = $.getElementSize(this.element); + var eltSize = this.size = $.getElementSize(this.elementWrapper); if (this.width === null) { width = eltSize.x; } @@ -19164,10 +19414,10 @@ $.Tile.prototype = { }(OpenSeadragon)); /* - * OpenSeadragon - Drawer + * OpenSeadragon - DrawerBase * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -19199,141 +19449,193 @@ $.Tile.prototype = { (function( $ ){ + const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc /** - * @class Drawer - * @memberof OpenSeadragon - * @classdesc Handles rendering of tiles for an {@link OpenSeadragon.Viewer}. + * @class OpenSeadragon.DrawerBase + * @classdesc Base class for Drawers that handle rendering of tiles for an {@link OpenSeadragon.Viewer}. * @param {Object} options - Options for this Drawer. * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. - * @param {Element} options.element - Parent element. - * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + * @param {HTMLElement} options.element - Parent element. + * @abstract */ -$.Drawer = function( options ) { - $.console.assert( options.viewer, "[Drawer] options.viewer is required" ); +OpenSeadragon.DrawerBase = class DrawerBase{ + constructor(options){ + $.console.assert( options.viewer, "[Drawer] options.viewer is required" ); + $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); + $.console.assert( options.element, "[Drawer] options.element is required" ); - //backward compatibility for positional args while preferring more - //idiomatic javascript options object as the only argument - var args = arguments; + this.viewer = options.viewer; + this.viewport = options.viewport; + this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; + this.options = options.options || {}; - if( !$.isPlainObject( options ) ){ - options = { - source: args[ 0 ], // Reference to Viewer tile source. - viewport: args[ 1 ], // Reference to Viewer viewport. - element: args[ 2 ] // Parent element. - }; + this.container = $.getElement( options.element ); + + this._renderingTarget = this._createDrawingElement(); + + + this.canvas.style.width = "100%"; + this.canvas.style.height = "100%"; + this.canvas.style.position = "absolute"; + // set canvas.style.left = 0 so the canvas is positioned properly in ltr and rtl html + this.canvas.style.left = "0"; + $.setElementOpacity( this.canvas, this.viewer.opacity, true ); + + // Allow pointer events to pass through the canvas element so implicit + // pointer capture works on touch devices + $.setElementPointerEventsNone( this.canvas ); + $.setElementTouchActionNone( this.canvas ); + + // explicit left-align + this.container.style.textAlign = "left"; + this.container.appendChild( this.canvas ); + + this._checkForAPIOverrides(); } - $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); - $.console.assert( options.element, "[Drawer] options.element is required" ); + // protect the canvas member with a getter + get canvas(){ + return this._renderingTarget; + } + get element(){ + $.console.error('Drawer.element is deprecated. Use Drawer.container instead.'); + return this.container; + } - if ( options.source ) { - $.console.error( "[Drawer] options.source is no longer accepted; use TiledImage instead" ); + /** + * @abstract + * @returns {String | undefined} What type of drawer this is. Must be overridden by extending classes. + */ + getType(){ + $.console.error('Drawer.getType must be implemented by child class'); + return undefined; } - this.viewer = options.viewer; - this.viewport = options.viewport; - this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; - if (options.opacity) { - $.console.error( "[Drawer] options.opacity is no longer accepted; set the opacity on the TiledImage instead" ); + /** + * @abstract + * @returns {Boolean} Whether the drawer implementation is supported by the browser. Must be overridden by extending classes. + */ + static isSupported() { + $.console.error('Drawer.isSupported must be implemented by child class'); } - this.useCanvas = $.supportsCanvas && ( this.viewer ? this.viewer.useCanvas : true ); /** - * The parent element of this Drawer instance, passed in when the Drawer was created. - * The parent of {@link OpenSeadragon.Drawer#canvas}. - * @member {Element} container - * @memberof OpenSeadragon.Drawer# + * @abstract + * @returns {Element} the element to draw into + * @private */ - this.container = $.getElement( options.element ); + _createDrawingElement() { + $.console.error('Drawer._createDrawingElement must be implemented by child class'); + return null; + } + /** - * A <canvas> element if the browser supports them, otherwise a <div> element. - * Child element of {@link OpenSeadragon.Drawer#container}. - * @member {Element} canvas - * @memberof OpenSeadragon.Drawer# + * @abstract + * @param {Array} tiledImages - An array of TiledImages that are ready to be drawn. + * @private */ - this.canvas = $.makeNeutralElement( this.useCanvas ? "canvas" : "div" ); + draw(tiledImages) { + $.console.error('Drawer.draw must be implemented by child class'); + } + /** - * 2d drawing context for {@link OpenSeadragon.Drawer#canvas} if it's a <canvas> element, otherwise null. - * @member {Object} context - * @memberof OpenSeadragon.Drawer# + * @abstract + * @returns {Boolean} True if rotation is supported. */ - this.context = this.useCanvas ? this.canvas.getContext( "2d" ) : null; + canRotate() { + $.console.error('Drawer.canRotate must be implemented by child class'); + } /** - * Sketch canvas used to temporarily draw tiles which cannot be drawn directly - * to the main canvas due to opacity. Lazily initialized. + * @abstract */ - this.sketchCanvas = null; - this.sketchContext = null; + destroy() { + $.console.error('Drawer.destroy must be implemented by child class'); + } /** - * @member {Element} element - * @memberof OpenSeadragon.Drawer# - * @deprecated Alias for {@link OpenSeadragon.Drawer#container}. + * @param {TiledImage} tiledImage the tiled image that is calling the function + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + * @private */ - this.element = this.container; + minimumOverlapRequired(tiledImage) { + return false; + } - // We force our container to ltr because our drawing math doesn't work in rtl. - // This issue only affects our canvas renderer, but we do it always for consistency. - // Note that this means overlays you want to be rtl need to be explicitly set to rtl. - this.container.dir = 'ltr'; - // check canvas available width and height, set canvas width and height such that the canvas backing store is set to the proper pixel density - if (this.useCanvas) { - var viewportSize = this._calculateCanvasSize(); - this.canvas.width = viewportSize.x; - this.canvas.height = viewportSize.y; + /** + * @abstract + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(imageSmoothingEnabled){ + $.console.error('Drawer.setImageSmoothingEnabled must be implemented by child class'); } - this.canvas.style.width = "100%"; - this.canvas.style.height = "100%"; - this.canvas.style.position = "absolute"; - $.setElementOpacity( this.canvas, this.opacity, true ); - // Allow pointer events to pass through the canvas element so implicit - // pointer capture works on touch devices - $.setElementPointerEventsNone( this.canvas ); - $.setElementTouchActionNone( this.canvas ); + /** + * Optional public API to draw a rectangle (e.g. for debugging purposes) + * Child classes can override this method if they wish to support this + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect) { + $.console.warn('[drawer].drawDebuggingRect is not implemented by this drawer'); + } - // explicit left-align - this.container.style.textAlign = "left"; - this.container.appendChild( this.canvas ); + // Deprecated functions + clear(){ + $.console.warn('[drawer].clear() is deprecated. The drawer is responsible for clearing itself as needed before drawing tiles.'); + } - // Image smoothing for canvas rendering (only if canvas is used). - // Canvas default is "true", so this will only be changed if user specified "false". - this._imageSmoothingEnabled = true; -}; + // Private functions -/** @lends OpenSeadragon.Drawer.prototype */ -$.Drawer.prototype = { - // deprecated - addOverlay: function( element, location, placement, onDraw ) { - $.console.error("drawer.addOverlay is deprecated. Use viewer.addOverlay instead."); - this.viewer.addOverlay( element, location, placement, onDraw ); - return this; - }, + /** + * Ensures that child classes have provided implementations for public API methods + * draw, canRotate, destroy, and setImageSmoothinEnabled. Throws an exception if the original + * placeholder methods are still in place. + * @private + * + */ + _checkForAPIOverrides(){ + if(this._createDrawingElement === $.DrawerBase.prototype._createDrawingElement){ + throw(new Error("[drawer]._createDrawingElement must be implemented by child class")); + } + if(this.draw === $.DrawerBase.prototype.draw){ + throw(new Error("[drawer].draw must be implemented by child class")); + } + if(this.canRotate === $.DrawerBase.prototype.canRotate){ + throw(new Error("[drawer].canRotate must be implemented by child class")); + } + if(this.destroy === $.DrawerBase.prototype.destroy){ + throw(new Error("[drawer].destroy must be implemented by child class")); + } + if(this.setImageSmoothingEnabled === $.DrawerBase.prototype.setImageSmoothingEnabled){ + throw(new Error("[drawer].setImageSmoothingEnabled must be implemented by child class")); + } + } - // deprecated - updateOverlay: function( element, location, placement ) { - $.console.error("drawer.updateOverlay is deprecated. Use viewer.updateOverlay instead."); - this.viewer.updateOverlay( element, location, placement ); - return this; - }, - // deprecated - removeOverlay: function( element ) { - $.console.error("drawer.removeOverlay is deprecated. Use viewer.removeOverlay instead."); - this.viewer.removeOverlay( element ); - return this; - }, + // Utility functions - // deprecated - clearOverlays: function() { - $.console.error("drawer.clearOverlays is deprecated. Use viewer.clearOverlays instead."); - this.viewer.clearOverlays(); - return this; - }, + /** + * Scale from OpenSeadragon viewer rectangle to drawer rectangle + * (ignoring rotation) + * @param {OpenSeadragon.Rect} rectangle - The rectangle in viewport coordinate system. + * @returns {OpenSeadragon.Rect} Rectangle in drawer coordinate system. + */ + viewportToDrawerRectangle(rectangle) { + var topLeft = this.viewport.pixelFromPointNoRotate(rectangle.getTopLeft(), true); + var size = this.viewport.deltaPixelsFromPointsNoRotate(rectangle.getSize(), true); + + return new $.Rect( + topLeft.x * $.pixelDensityRatio, + topLeft.y * $.pixelDensityRatio, + size.x * $.pixelDensityRatio, + size.y * $.pixelDensityRatio + ); + } /** * This function converts the given point from to the drawer coordinate by @@ -19343,591 +19645,2773 @@ $.Drawer.prototype = { * @param {OpenSeadragon.Point} point - the pixel point to convert * @returns {OpenSeadragon.Point} Point in drawer coordinate system. */ - viewportCoordToDrawerCoord: function(point) { + viewportCoordToDrawerCoord(point) { var vpPoint = this.viewport.pixelFromPointNoRotate(point, true); return new $.Point( vpPoint.x * $.pixelDensityRatio, vpPoint.y * $.pixelDensityRatio ); - }, + } + + + // Internal utility functions /** - * This function will create multiple polygon paths on the drawing context by provided polygons, - * then clip the context to the paths. - * @param {OpenSeadragon.Point[][]} polygons - an array of polygons. A polygon is an array of OpenSeadragon.Point - * @param {Boolean} useSketch - Whether to use the sketch canvas or not. + * Calculate width and height of the canvas based on viewport dimensions + * and pixelDensityRatio + * @private + * @returns {OpenSeadragon.Point} {x, y} size of the canvas */ - clipWithPolygons: function (polygons, useSketch) { - if (!this.useCanvas) { - return; - } - var context = this._getContext(useSketch); - context.beginPath(); - polygons.forEach(function (polygon) { - polygon.forEach(function (coord, i) { - context[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); - }); - }); - context.clip(); - }, + _calculateCanvasSize() { + var pixelDensityRatio = $.pixelDensityRatio; + var viewportSize = this.viewport.getContainerSize(); + return new OpenSeadragon.Point( Math.round(viewportSize.x * pixelDensityRatio), Math.round(viewportSize.y * pixelDensityRatio)); + } /** - * Set the opacity of the drawer. - * @param {Number} opacity - * @returns {OpenSeadragon.Drawer} Chainable. + * Called by implementations to fire the tiled-image-drawn event (used by tests) + * @private */ - setOpacity: function( opacity ) { - $.console.error("drawer.setOpacity is deprecated. Use tiledImage.setOpacity instead."); - var world = this.viewer.world; - for (var i = 0; i < world.getItemCount(); i++) { - world.getItemAt( i ).setOpacity( opacity ); + _raiseTiledImageDrawnEvent(tiledImage, tiles){ + if(!this.viewer) { + return; } - return this; - }, + + /** + * Raised when a tiled image is drawn to the canvas. Used internally for testing. + * The update-viewport event is preferred if you want to know when a frame has been drawn. + * + * @event tiled-image-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {Array} tiles - An array of Tile objects that were drawn. + * @property {?Object} userData - Arbitrary subscriber-defined object. + * @private + */ + this.viewer.raiseEvent( 'tiled-image-drawn', { + tiledImage: tiledImage, + tiles: tiles, + }); + } /** - * Get the opacity of the drawer. - * @returns {Number} + * Called by implementations to fire the drawer-error event + * @private */ - getOpacity: function() { - $.console.error("drawer.getOpacity is deprecated. Use tiledImage.getOpacity instead."); - var world = this.viewer.world; - var maxOpacity = 0; - for (var i = 0; i < world.getItemCount(); i++) { - var opacity = world.getItemAt( i ).getOpacity(); - if ( opacity > maxOpacity ) { - maxOpacity = opacity; - } + _raiseDrawerErrorEvent(tiledImage, errorMessage){ + if(!this.viewer) { + return; } - return maxOpacity; - }, - // deprecated - needsUpdate: function() { - $.console.error( "[Drawer.needsUpdate] this function is deprecated. Use World.needsDraw instead." ); - return this.viewer.world.needsDraw(); - }, + /** + * Raised when a tiled image is drawn to the canvas. Used internally for testing. + * The update-viewport event is preferred if you want to know when a frame has been drawn. + * + * @event drawer-error + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.DrawerBase} drawer - The drawer that raised the error. + * @property {String} error - A message describing the error. + * @property {?Object} userData - Arbitrary subscriber-defined object. + * @private + */ + this.viewer.raiseEvent( 'drawer-error', { + tiledImage: tiledImage, + drawer: this, + error: errorMessage, + }); + } - // deprecated - numTilesLoaded: function() { - $.console.error( "[Drawer.numTilesLoaded] this function is deprecated. Use TileCache.numTilesLoaded instead." ); - return this.viewer.tileCache.numTilesLoaded(); - }, - // deprecated - reset: function() { - $.console.error( "[Drawer.reset] this function is deprecated. Use World.resetItems instead." ); - this.viewer.world.resetItems(); - return this; - }, +}; - // deprecated - update: function() { - $.console.error( "[Drawer.update] this function is deprecated. Use Drawer.clear and World.draw instead." ); - this.clear(); - this.viewer.world.draw(); - return this; - }, +}( OpenSeadragon )); + +/* + * OpenSeadragon - HTMLDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + + const OpenSeadragon = $; // alias back for JSDoc + +/** + * @class OpenSeadragon.HTMLDrawer + * @extends OpenSeadragon.DrawerBase + * @classdesc HTML-based implementation of DrawerBase for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class HTMLDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (div) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.HTMLDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.HTMLDrawer# + */ + + // Reject listening for the tile-drawing event, which this drawer does not fire + this.viewer.rejectEventHandler("tile-drawing", "The HTMLDrawer does not raise the tile-drawing event"); + // Since the tile-drawn event is fired by this drawer, make sure handlers can be added for it + this.viewer.allowEventHandler("tile-drawn"); + } /** - * @returns {Boolean} True if rotation is supported. + * @returns {Boolean} always true */ - canRotate: function() { - return this.useCanvas; - }, + static isSupported(){ + return true; + } + + /** + * + * @returns 'html' + */ + getType(){ + return 'html'; + } + + /** + * @param {TiledImage} tiledImage the tiled image that is calling the function + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + * @private + */ + minimumOverlapRequired(tiledImage) { + return true; + } + + /** + * create the HTML element (e.g. canvas, div) that the image will be drawn into + * @returns {Element} the div to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("div"); + return canvas; + } + + /** + * Draws the TiledImages + */ + draw(tiledImages) { + var _this = this; + this._prepareNewFrame(); // prepare to draw a new frame + tiledImages.forEach(function(tiledImage){ + if (tiledImage.opacity !== 0) { + _this._drawTiles(tiledImage); + } + }); + + } + + /** + * @returns {Boolean} False - rotation is not supported. + */ + canRotate() { + return false; + } /** * Destroy the drawer (unload current loaded tiles) */ - destroy: function() { - //force unloading of current canvas (1x1 will be gc later, trick not necessarily needed) - this.canvas.width = 1; - this.canvas.height = 1; - this.sketchCanvas = null; - this.sketchContext = null; - }, + destroy() { + this.container.removeChild(this.canvas); + } + + /** + * This function is ignored by the HTML Drawer. Implementing it is required by DrawerBase. + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(){ + // noop - HTML Drawer does not deal with this property + } /** * Clears the Drawer so it's ready to draw another frame. + * @private + * */ - clear: function() { + _prepareNewFrame() { this.canvas.innerHTML = ""; - if ( this.useCanvas ) { - var viewportSize = this._calculateCanvasSize(); - if( this.canvas.width !== viewportSize.x || - this.canvas.height !== viewportSize.y ) { - this.canvas.width = viewportSize.x; - this.canvas.height = viewportSize.y; - this._updateImageSmoothingEnabled(this.context); - if ( this.sketchCanvas !== null ) { - var sketchCanvasSize = this._calculateSketchCanvasSize(); - this.sketchCanvas.width = sketchCanvasSize.x; - this.sketchCanvas.height = sketchCanvasSize.y; - this._updateImageSmoothingEnabled(this.sketchContext); - } - } - this._clear(); - } - }, + } - _clear: function (useSketch, bounds) { - if (!this.useCanvas) { + /** + * Draws a TiledImage. + * @private + * + */ + _drawTiles( tiledImage ) { + var lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); + if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) { return; } - var context = this._getContext(useSketch); - if (bounds) { - context.clearRect(bounds.x, bounds.y, bounds.width, bounds.height); - } else { - var canvas = context.canvas; - context.clearRect(0, 0, canvas.width, canvas.height); - } - }, - /** - * Scale from OpenSeadragon viewer rectangle to drawer rectangle - * (ignoring rotation) - * @param {OpenSeadragon.Rect} rectangle - The rectangle in viewport coordinate system. - * @returns {OpenSeadragon.Rect} Rectangle in drawer coordinate system. - */ - viewportToDrawerRectangle: function(rectangle) { - var topLeft = this.viewport.pixelFromPointNoRotate(rectangle.getTopLeft(), true); - var size = this.viewport.deltaPixelsFromPointsNoRotate(rectangle.getSize(), true); + // Iterate over the tiles to draw, and draw them + for (var i = lastDrawn.length - 1; i >= 0; i--) { + var tile = lastDrawn[ i ]; + this._drawTile( tile ); - return new $.Rect( - topLeft.x * $.pixelDensityRatio, - topLeft.y * $.pixelDensityRatio, - size.x * $.pixelDensityRatio, - size.y * $.pixelDensityRatio - ); - }, + if( this.viewer ){ + /** + * Raised when a tile is drawn to the canvas. Only valid for + * context2d and html drawers. + * + * @event tile-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'tile-drawn', { + tiledImage: tiledImage, + tile: tile + }); + } + } + + } /** * Draws the given tile. + * @private * @param {OpenSeadragon.Tile} tile - The tile to draw. * @param {Function} drawingHandler - Method for firing the drawing event if using canvas. * drawingHandler({context, tile, rendered}) - * @param {Boolean} useSketch - Whether to use the sketch canvas or not. - * where rendered is the context with the pre-drawn image. - * @param {Float} [scale=1] - Apply a scale to tile position and size. Defaults to 1. - * @param {OpenSeadragon.Point} [translate] A translation vector to offset tile position - * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round - * position and size of tiles supporting alpha channel in non-transparency - * context. - * @param {OpenSeadragon.TileSource} source - The source specification of the tile. */ - drawTile: function( tile, drawingHandler, useSketch, scale, translate, shouldRoundPositionAndSize, source) { - $.console.assert(tile, '[Drawer.drawTile] tile is required'); - $.console.assert(drawingHandler, '[Drawer.drawTile] drawingHandler is required'); + _drawTile( tile ) { + $.console.assert(tile, '[Drawer._drawTile] tile is required'); - if (this.useCanvas) { - var context = this._getContext(useSketch); - scale = scale || 1; - tile.drawCanvas(context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source); - } else { - tile.drawHTML( this.canvas ); - } - }, - - _getContext: function( useSketch ) { - var context = this.context; - if ( useSketch ) { - if (this.sketchCanvas === null) { - this.sketchCanvas = document.createElement( "canvas" ); - var sketchCanvasSize = this._calculateSketchCanvasSize(); - this.sketchCanvas.width = sketchCanvasSize.x; - this.sketchCanvas.height = sketchCanvasSize.y; - this.sketchContext = this.sketchCanvas.getContext( "2d" ); + let container = this.canvas; - // If the viewport is not currently rotated, the sketchCanvas - // will have the same size as the main canvas. However, if - // the viewport get rotated later on, we will need to resize it. - if (this.viewport.getRotation() === 0) { - var self = this; - this.viewer.addHandler('rotate', function resizeSketchCanvas() { - if (self.viewport.getRotation() === 0) { - return; - } - self.viewer.removeHandler('rotate', resizeSketchCanvas); - var sketchCanvasSize = self._calculateSketchCanvasSize(); - self.sketchCanvas.width = sketchCanvasSize.x; - self.sketchCanvas.height = sketchCanvasSize.y; - }); - } - this._updateImageSmoothingEnabled(this.sketchContext); - } - context = this.sketchContext; + if (!tile.cacheImageRecord) { + $.console.warn( + '[Drawer._drawTileToHTML] attempting to draw tile %s when it\'s not cached', + tile.toString()); + return; } - return context; - }, - // private - saveContext: function( useSketch ) { - if (!this.useCanvas) { + if ( !tile.loaded ) { + $.console.warn( + "Attempting to draw tile %s when it's not yet loaded.", + tile.toString() + ); return; } - this._getContext( useSketch ).save(); - }, + //EXPERIMENTAL - trying to figure out how to scale the container + // content during animation of the container size. - // private - restoreContext: function( useSketch ) { - if (!this.useCanvas) { - return; - } + if ( !tile.element ) { + var image = tile.getImage(); + if (!image) { + return; + } - this._getContext( useSketch ).restore(); - }, + tile.element = $.makeNeutralElement( "div" ); + tile.imgElement = image.cloneNode(); + tile.imgElement.style.msInterpolationMode = "nearest-neighbor"; + tile.imgElement.style.width = "100%"; + tile.imgElement.style.height = "100%"; - // private - setClip: function(rect, useSketch) { - if (!this.useCanvas) { - return; + tile.style = tile.element.style; + tile.style.position = "absolute"; } - var context = this._getContext( useSketch ); - context.beginPath(); - context.rect(rect.x, rect.y, rect.width, rect.height); - context.clip(); - }, + if ( tile.element.parentNode !== container ) { + container.appendChild( tile.element ); + } + if ( tile.imgElement.parentNode !== tile.element ) { + tile.element.appendChild( tile.imgElement ); + } - // private - drawRectangle: function(rect, fillStyle, useSketch) { - if (!this.useCanvas) { - return; + tile.style.top = tile.position.y + "px"; + tile.style.left = tile.position.x + "px"; + tile.style.height = tile.size.y + "px"; + tile.style.width = tile.size.x + "px"; + + if (tile.flipped) { + tile.style.transform = "scaleX(-1)"; } - var context = this._getContext( useSketch ); - context.save(); - context.fillStyle = fillStyle; - context.fillRect(rect.x, rect.y, rect.width, rect.height); - context.restore(); - }, + $.setElementOpacity( tile.element, tile.opacity ); + } - /** - * Blends the sketch canvas in the main canvas. - * @param {Object} options The options - * @param {Float} options.opacity The opacity of the blending. - * @param {Float} [options.scale=1] The scale at which tiles were drawn on - * the sketch. Default is 1. - * Use scale to draw at a lower scale and then enlarge onto the main canvas. - * @param {OpenSeadragon.Point} [options.translate] A translation vector - * that was used to draw the tiles - * @param {String} [options.compositeOperation] - How the image is - * composited onto other images; see compositeOperation in - * {@link OpenSeadragon.Options} for possible values. - * @param {OpenSeadragon.Rect} [options.bounds] The part of the sketch - * canvas to blend in the main canvas. If specified, options.scale and - * options.translate get ignored. +} + +$.HTMLDrawer = HTMLDrawer; + + +}( OpenSeadragon )); + +/* + * OpenSeadragon - CanvasDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + + const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc +/** + * @class OpenSeadragon.CanvasDrawer + * @extends OpenSeadragon.DrawerBase + * @classdesc Default implementation of CanvasDrawer for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class CanvasDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (canvas) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.CanvasDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.CanvasDrawer# + */ + + /** + * 2d drawing context for {@link OpenSeadragon.CanvasDrawer#canvas}. + * @member {Object} context + * @memberof OpenSeadragon.CanvasDrawer# + * @private + */ + this.context = this.canvas.getContext( '2d' ); + + // Sketch canvas used to temporarily draw tiles which cannot be drawn directly + // to the main canvas due to opacity. Lazily initialized. + this.sketchCanvas = null; + this.sketchContext = null; + + // Image smoothing for canvas rendering (only if canvas is used). + // Canvas default is "true", so this will only be changed if user specifies "false" in the options or via setImageSmoothinEnabled. + this._imageSmoothingEnabled = true; + + // Since the tile-drawn and tile-drawing events are fired by this drawer, make sure handlers can be added for them + this.viewer.allowEventHandler("tile-drawn"); + this.viewer.allowEventHandler("tile-drawing"); + + } + + /** + * @returns {Boolean} true if canvas is supported by the browser, otherwise false */ - blendSketch: function(opacity, scale, translate, compositeOperation) { - var options = opacity; - if (!$.isPlainObject(options)) { - options = { - opacity: opacity, - scale: scale, - translate: translate, - compositeOperation: compositeOperation + static isSupported(){ + return $.supportsCanvas; + } + + getType(){ + return 'canvas'; + } + + /** + * create the HTML element (e.g. canvas, div) that the image will be drawn into + * @returns {Element} the canvas to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("canvas"); + let viewportSize = this._calculateCanvasSize(); + canvas.width = viewportSize.x; + canvas.height = viewportSize.y; + return canvas; + } + + /** + * Draws the TiledImages + */ + draw(tiledImages) { + this._prepareNewFrame(); // prepare to draw a new frame + if(this.viewer.viewport.getFlip() !== this._viewportFlipped){ + this._flip(); + } + for(const tiledImage of tiledImages){ + if (tiledImage.opacity !== 0) { + this._drawTiles(tiledImage); + } + } + } + + /** + * @returns {Boolean} True - rotation is supported. + */ + canRotate() { + return true; + } + + /** + * Destroy the drawer (unload current loaded tiles) + */ + destroy() { + //force unloading of current canvas (1x1 will be gc later, trick not necessarily needed) + this.canvas.width = 1; + this.canvas.height = 1; + this.sketchCanvas = null; + this.sketchContext = null; + this.container.removeChild(this.canvas); + } + + /** + * @param {TiledImage} tiledImage the tiled image that is calling the function + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + * @private + */ + minimumOverlapRequired(tiledImage) { + return true; + } + + + /** + * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property. + * + * @function + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(imageSmoothingEnabled){ + this._imageSmoothingEnabled = !!imageSmoothingEnabled; + this._updateImageSmoothingEnabled(this.context); + this.viewer.forceRedraw(); + } + + /** + * Draw a rectangle onto the canvas + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect) { + var context = this.context; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.strokeStyle = this.debugGridColor[0]; + context.fillStyle = this.debugGridColor[0]; + + context.strokeRect( + rect.x * $.pixelDensityRatio, + rect.y * $.pixelDensityRatio, + rect.width * $.pixelDensityRatio, + rect.height * $.pixelDensityRatio + ); + + context.restore(); + } + + /** + * Test whether the current context is flipped or not + * @private + */ + get _viewportFlipped(){ + return this.context.getTransform().a < 0; + } + + /** + * Fires the tile-drawing event. + * @private + */ + _raiseTileDrawingEvent(tiledImage, context, tile, rendered){ + /** + * This event is fired just before the tile is drawn giving the application a chance to alter the image. + * + * NOTE: This event is only fired when the 'canvas' drawer is being used + * + * @event tile-drawing + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.Tile} tile - The Tile being drawn. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {CanvasRenderingContext2D} context - The HTML canvas context being drawn into. + * @property {CanvasRenderingContext2D} rendered - The HTML canvas context containing the tile imagery. + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent('tile-drawing', { + tiledImage: tiledImage, + context: context, + tile: tile, + rendered: rendered + }); + } + + /** + * Clears the Drawer so it's ready to draw another frame. + * @private + * + */ + _prepareNewFrame() { + var viewportSize = this._calculateCanvasSize(); + if( this.canvas.width !== viewportSize.x || + this.canvas.height !== viewportSize.y ) { + this.canvas.width = viewportSize.x; + this.canvas.height = viewportSize.y; + this._updateImageSmoothingEnabled(this.context); + if ( this.sketchCanvas !== null ) { + var sketchCanvasSize = this._calculateSketchCanvasSize(); + this.sketchCanvas.width = sketchCanvasSize.x; + this.sketchCanvas.height = sketchCanvasSize.y; + this._updateImageSmoothingEnabled(this.sketchContext); + } + } + this._clear(); + } + + /** + * @private + * @param {Boolean} useSketch Whether to clear sketch canvas or main canvas + * @param {OpenSeadragon.Rect} [bounds] The rectangle to clear + */ + _clear(useSketch, bounds){ + var context = this._getContext(useSketch); + if (bounds) { + context.clearRect(bounds.x, bounds.y, bounds.width, bounds.height); + } else { + var canvas = context.canvas; + context.clearRect(0, 0, canvas.width, canvas.height); + } + } + + /** + * Draws a TiledImage. + * @private + * + */ + _drawTiles( tiledImage ) { + var lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); + if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) { + return; + } + + var tile = lastDrawn[0]; + var useSketch; + + if (tile) { + useSketch = tiledImage.opacity < 1 || + (tiledImage.compositeOperation && tiledImage.compositeOperation !== 'source-over') || + (!tiledImage._isBottomItem() && + tiledImage.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); + } + + var sketchScale; + var sketchTranslate; + + var zoom = this.viewport.getZoom(true); + var imageZoom = tiledImage.viewportToImageZoom(zoom); + + if (lastDrawn.length > 1 && + imageZoom > tiledImage.smoothTileEdgesMinZoom && + !tiledImage.iOSDevice && + tiledImage.getRotation(true) % 360 === 0 ){ // TODO: support tile edge smoothing with tiled image rotation. + // When zoomed in a lot (>100%) the tile edges are visible. + // So we have to composite them at ~100% and scale them up together. + // Note: Disabled on iOS devices per default as it causes a native crash + useSketch = true; + sketchScale = tile.getScaleForEdgeSmoothing(); + sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale, + this._getCanvasSize(false), + this._getCanvasSize(true)); + } + + var bounds; + if (useSketch) { + if (!sketchScale) { + // Except when edge smoothing, we only clean the part of the + // sketch canvas we are going to use for performance reasons. + bounds = this.viewport.viewportToViewerElementRectangle( + tiledImage.getClippedBounds(true)) + .getIntegerBoundingBox(); + + bounds = bounds.times($.pixelDensityRatio); + } + this._clear(true, bounds); + } + + // When scaling, we must rotate only when blending the sketch canvas to + // avoid interpolation + if (!sketchScale) { + this._setRotations(tiledImage, useSketch); + } + + var usedClip = false; + if ( tiledImage._clip ) { + this._saveContext(useSketch); + + var box = tiledImage.imageToViewportRectangle(tiledImage._clip, true); + box = box.rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); + var clipRect = this.viewportToDrawerRectangle(box); + if (sketchScale) { + clipRect = clipRect.times(sketchScale); + } + if (sketchTranslate) { + clipRect = clipRect.translate(sketchTranslate); + } + this._setClip(clipRect, useSketch); + + usedClip = true; + } + + if (tiledImage._croppingPolygons) { + var self = this; + if(!usedClip){ + this._saveContext(useSketch); + } + try { + var polygons = tiledImage._croppingPolygons.map(function (polygon) { + return polygon.map(function (coord) { + var point = tiledImage + .imageToViewportCoordinates(coord.x, coord.y, true) + .rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); + var clipPoint = self.viewportCoordToDrawerCoord(point); + if (sketchScale) { + clipPoint = clipPoint.times(sketchScale); + } + if (sketchTranslate) { // mostly fixes #2312 + clipPoint = clipPoint.plus(sketchTranslate); + } + return clipPoint; + }); + }); + this._clipWithPolygons(polygons, useSketch); + } catch (e) { + $.console.error(e); + } + usedClip = true; + } + tiledImage._hasOpaqueTile = false; + if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) { + let placeholderRect = this.viewportToDrawerRectangle(tiledImage.getBoundsNoRotate(true)); + if (sketchScale) { + placeholderRect = placeholderRect.times(sketchScale); + } + if (sketchTranslate) { + placeholderRect = placeholderRect.translate(sketchTranslate); + } + + let fillStyle = null; + if ( typeof tiledImage.placeholderFillStyle === "function" ) { + fillStyle = tiledImage.placeholderFillStyle(tiledImage, this.context); + } + else { + fillStyle = tiledImage.placeholderFillStyle; + } + + this._drawRectangle(placeholderRect, fillStyle, useSketch); + } + + var subPixelRoundingRule = determineSubPixelRoundingRule(tiledImage.subPixelRoundingForTransparency); + + var shouldRoundPositionAndSize = false; + + if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) { + shouldRoundPositionAndSize = true; + } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) { + var isAnimating = this.viewer && this.viewer.isAnimating(); + shouldRoundPositionAndSize = !isAnimating; + } + + // Iterate over the tiles to draw, and draw them + for (var i = 0; i < lastDrawn.length; i++) { + tile = lastDrawn[ i ]; + this._drawTile( tile, tiledImage, useSketch, sketchScale, + sketchTranslate, shouldRoundPositionAndSize, tiledImage.source ); + + if( this.viewer ){ + /** + * Raised when a tile is drawn to the canvas. Only valid for + * context2d and html drawers. + * + * @event tile-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'tile-drawn', { + tiledImage: tiledImage, + tile: tile + }); + } + } + + if ( usedClip ) { + this._restoreContext( useSketch ); + } + + if (!sketchScale) { + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(useSketch); + } + if (this.viewport.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(useSketch); + } + } + + if (useSketch) { + if (sketchScale) { + this._setRotations(tiledImage); + } + this.blendSketch({ + opacity: tiledImage.opacity, + scale: sketchScale, + translate: sketchTranslate, + compositeOperation: tiledImage.compositeOperation, + bounds: bounds + }); + if (sketchScale) { + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(false); + } + if (this.viewport.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(false); + } + } + } + + this._drawDebugInfo( tiledImage, lastDrawn ); + + // Fire tiled-image-drawn event. + + this._raiseTiledImageDrawnEvent(tiledImage, lastDrawn); + + } + + /** + * Draws special debug information for a TiledImage if in debug mode. + * @private + * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. + */ + _drawDebugInfo( tiledImage, lastDrawn ) { + if( tiledImage.debugMode ) { + for ( var i = lastDrawn.length - 1; i >= 0; i-- ) { + var tile = lastDrawn[ i ]; + try { + this._drawDebugInfoOnTile(tile, lastDrawn.length, i, tiledImage); + } catch(e) { + $.console.error(e); + } + } + } + } + + /** + * This function will create multiple polygon paths on the drawing context by provided polygons, + * then clip the context to the paths. + * @private + * @param {OpenSeadragon.Point[][]} polygons - an array of polygons. A polygon is an array of OpenSeadragon.Point + * @param {Boolean} useSketch - Whether to use the sketch canvas or not. + */ + _clipWithPolygons (polygons, useSketch) { + var context = this._getContext(useSketch); + context.beginPath(); + for(const polygon of polygons){ + for(const [i, coord] of polygon.entries() ){ + context[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); + } + } + + context.clip(); + } + + /** + * Draws the given tile. + * @private + * @param {OpenSeadragon.Tile} tile - The tile to draw. + * @param {OpenSeadragon.TiledImage} tiledImage - The tiled image being drawn. + * @param {Boolean} useSketch - Whether to use the sketch canvas or not. + * where rendered is the context with the pre-drawn image. + * @param {Float} [scale=1] - Apply a scale to tile position and size. Defaults to 1. + * @param {OpenSeadragon.Point} [translate] A translation vector to offset tile position + * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round + * position and size of tiles supporting alpha channel in non-transparency + * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. + */ + _drawTile( tile, tiledImage, useSketch, scale, translate, shouldRoundPositionAndSize, source) { + $.console.assert(tile, '[Drawer._drawTile] tile is required'); + $.console.assert(tiledImage, '[Drawer._drawTile] drawingHandler is required'); + + var context = this._getContext(useSketch); + scale = scale || 1; + this._drawTileToCanvas(tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source); + + } + + /** + * Renders the tile in a canvas-based context. + * @private + * @function + * @param {OpenSeadragon.Tile} tile - the tile to draw to the canvas + * @param {Canvas} context + * @param {OpenSeadragon.TiledImage} tiledImage - Method for firing the drawing event. + * drawingHandler({context, tile, rendered}) + * where rendered is the context with the pre-drawn image. + * @param {Number} [scale=1] - Apply a scale to position and size + * @param {OpenSeadragon.Point} [translate] - A translation vector + * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round + * position and size of tiles supporting alpha channel in non-transparency + * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. + */ + _drawTileToCanvas( tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source) { + + var position = tile.position.times($.pixelDensityRatio), + size = tile.size.times($.pixelDensityRatio), + rendered; + + if (!tile.context2D && !tile.cacheImageRecord) { + $.console.warn( + '[Drawer._drawTileToCanvas] attempting to draw tile %s when it\'s not cached', + tile.toString()); + return; + } + + rendered = tile.getCanvasContext(); + + if ( !tile.loaded || !rendered ){ + $.console.warn( + "Attempting to draw tile %s when it's not yet loaded.", + tile.toString() + ); + + return; + } + + context.save(); + + if (typeof scale === 'number' && scale !== 1) { + // draw tile at a different scale + position = position.times(scale); + size = size.times(scale); + } + + if (translate instanceof $.Point) { + // shift tile position slightly + position = position.plus(translate); + } + + //if we are supposed to be rendering fully opaque rectangle, + //ie its done fading or fading is turned off, and if we are drawing + //an image with an alpha channel, then the only way + //to avoid seeing the tile underneath is to clear the rectangle + if (context.globalAlpha === 1 && tile.hasTransparency) { + if (shouldRoundPositionAndSize) { + // Round to the nearest whole pixel so we don't get seams from overlap. + position.x = Math.round(position.x); + position.y = Math.round(position.y); + size.x = Math.round(size.x); + size.y = Math.round(size.y); + } + + //clearing only the inside of the rectangle occupied + //by the png prevents edge flikering + context.clearRect( + position.x, + position.y, + size.x, + size.y + ); + } + + this._raiseTileDrawingEvent(tiledImage, context, tile, rendered); + + var sourceWidth, sourceHeight; + if (tile.sourceBounds) { + sourceWidth = Math.min(tile.sourceBounds.width, rendered.canvas.width); + sourceHeight = Math.min(tile.sourceBounds.height, rendered.canvas.height); + } else { + sourceWidth = rendered.canvas.width; + sourceHeight = rendered.canvas.height; + } + + context.translate(position.x + size.x / 2, 0); + if (tile.flipped) { + context.scale(-1, 1); + } + context.drawImage( + rendered.canvas, + 0, + 0, + sourceWidth, + sourceHeight, + -size.x / 2, + position.y, + size.x, + size.y + ); + + context.restore(); + } + + /** + * Get the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + * @returns {CanvasRenderingContext2D} + */ + _getContext( useSketch ) { + var context = this.context; + if ( useSketch ) { + if (this.sketchCanvas === null) { + this.sketchCanvas = document.createElement( "canvas" ); + var sketchCanvasSize = this._calculateSketchCanvasSize(); + this.sketchCanvas.width = sketchCanvasSize.x; + this.sketchCanvas.height = sketchCanvasSize.y; + this.sketchContext = this.sketchCanvas.getContext( "2d" ); + + // If the viewport is not currently rotated, the sketchCanvas + // will have the same size as the main canvas. However, if + // the viewport get rotated later on, we will need to resize it. + if (this.viewport.getRotation() === 0) { + var self = this; + this.viewer.addHandler('rotate', function resizeSketchCanvas() { + if (self.viewport.getRotation() === 0) { + return; + } + self.viewer.removeHandler('rotate', resizeSketchCanvas); + var sketchCanvasSize = self._calculateSketchCanvasSize(); + self.sketchCanvas.width = sketchCanvasSize.x; + self.sketchCanvas.height = sketchCanvasSize.y; + }); + } + this._updateImageSmoothingEnabled(this.sketchContext); + } + context = this.sketchContext; + } + return context; + } + + /** + * Save the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + */ + _saveContext( useSketch ) { + this._getContext( useSketch ).save(); + } + + /** + * Restore the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + */ + _restoreContext( useSketch ) { + this._getContext( useSketch ).restore(); + } + + // private + _setClip(rect, useSketch) { + var context = this._getContext( useSketch ); + context.beginPath(); + context.rect(rect.x, rect.y, rect.width, rect.height); + context.clip(); + } + + // private + // used to draw a placeholder rectangle + _drawRectangle(rect, fillStyle, useSketch) { + var context = this._getContext( useSketch ); + context.save(); + context.fillStyle = fillStyle; + context.fillRect(rect.x, rect.y, rect.width, rect.height); + context.restore(); + } + + /** + * Blends the sketch canvas in the main canvas. + * @param {Object} options The options + * @param {Float} options.opacity The opacity of the blending. + * @param {Float} [options.scale=1] The scale at which tiles were drawn on + * the sketch. Default is 1. + * Use scale to draw at a lower scale and then enlarge onto the main canvas. + * @param {OpenSeadragon.Point} [options.translate] A translation vector + * that was used to draw the tiles + * @param {String} [options.compositeOperation] - How the image is + * composited onto other images; see compositeOperation in + * {@link OpenSeadragon.Options} for possible values. + * @param {OpenSeadragon.Rect} [options.bounds] The part of the sketch + * canvas to blend in the main canvas. If specified, options.scale and + * options.translate get ignored. + */ + blendSketch(opacity, scale, translate, compositeOperation) { + var options = opacity; + if (!$.isPlainObject(options)) { + options = { + opacity: opacity, + scale: scale, + translate: translate, + compositeOperation: compositeOperation + }; + } + + opacity = options.opacity; + compositeOperation = options.compositeOperation; + var bounds = options.bounds; + + this.context.save(); + this.context.globalAlpha = opacity; + if (compositeOperation) { + this.context.globalCompositeOperation = compositeOperation; + } + if (bounds) { + // Internet Explorer, Microsoft Edge, and Safari have problems + // when you call context.drawImage with negative x or y + // or x + width or y + height greater than the canvas width or height respectively. + if (bounds.x < 0) { + bounds.width += bounds.x; + bounds.x = 0; + } + if (bounds.x + bounds.width > this.canvas.width) { + bounds.width = this.canvas.width - bounds.x; + } + if (bounds.y < 0) { + bounds.height += bounds.y; + bounds.y = 0; + } + if (bounds.y + bounds.height > this.canvas.height) { + bounds.height = this.canvas.height - bounds.y; + } + + this.context.drawImage( + this.sketchCanvas, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + bounds.x, + bounds.y, + bounds.width, + bounds.height + ); + } else { + scale = options.scale || 1; + translate = options.translate; + var position = translate instanceof $.Point ? + translate : new $.Point(0, 0); + + var widthExt = 0; + var heightExt = 0; + if (translate) { + var widthDiff = this.sketchCanvas.width - this.canvas.width; + var heightDiff = this.sketchCanvas.height - this.canvas.height; + widthExt = Math.round(widthDiff / 2); + heightExt = Math.round(heightDiff / 2); + } + this.context.drawImage( + this.sketchCanvas, + position.x - widthExt * scale, + position.y - heightExt * scale, + (this.canvas.width + 2 * widthExt) * scale, + (this.canvas.height + 2 * heightExt) * scale, + -widthExt, + -heightExt, + this.canvas.width + 2 * widthExt, + this.canvas.height + 2 * heightExt + ); + } + this.context.restore(); + } + + // private + _drawDebugInfoOnTile(tile, count, i, tiledImage) { + + var colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; + var context = this.context; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; + context.strokeStyle = this.debugGridColor[colorIndex]; + context.fillStyle = this.debugGridColor[colorIndex]; + + this._setRotations(tiledImage); + + if(this._viewportFlipped){ + this._flip({point: tile.position.plus(tile.size.divide(2))}); + } + + context.strokeRect( + tile.position.x * $.pixelDensityRatio, + tile.position.y * $.pixelDensityRatio, + tile.size.x * $.pixelDensityRatio, + tile.size.y * $.pixelDensityRatio + ); + + var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; + var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; + + // Rotate the text the right way around. + context.translate( tileCenterX, tileCenterY ); + const angleInDegrees = this.viewport.getRotation(true); + context.rotate( Math.PI / 180 * -angleInDegrees ); + context.translate( -tileCenterX, -tileCenterY ); + + if( tile.x === 0 && tile.y === 0 ){ + context.fillText( + "Zoom: " + this.viewport.getZoom(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 30) * $.pixelDensityRatio + ); + context.fillText( + "Pan: " + this.viewport.getBounds().toString(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 20) * $.pixelDensityRatio + ); + } + context.fillText( + "Level: " + tile.level, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 20) * $.pixelDensityRatio + ); + context.fillText( + "Column: " + tile.x, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 30) * $.pixelDensityRatio + ); + context.fillText( + "Row: " + tile.y, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 40) * $.pixelDensityRatio + ); + context.fillText( + "Order: " + i + " of " + count, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 50) * $.pixelDensityRatio + ); + context.fillText( + "Size: " + tile.size.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 60) * $.pixelDensityRatio + ); + context.fillText( + "Position: " + tile.position.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 70) * $.pixelDensityRatio + ); + + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._restoreRotationChanges(); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(); + } + + context.restore(); + } + + // private + _updateImageSmoothingEnabled(context){ + context.msImageSmoothingEnabled = this._imageSmoothingEnabled; + context.imageSmoothingEnabled = this._imageSmoothingEnabled; + } + + /** + * Get the canvas size + * @private + * @param {Boolean} sketch If set to true return the size of the sketch canvas + * @returns {OpenSeadragon.Point} The size of the canvas + */ + _getCanvasSize(sketch) { + var canvas = this._getContext(sketch).canvas; + return new $.Point(canvas.width, canvas.height); + } + + /** + * Get the canvas center + * @private + * @param {Boolean} sketch If set to true return the center point of the sketch canvas + * @returns {OpenSeadragon.Point} The center point of the canvas + */ + _getCanvasCenter() { + return new $.Point(this.canvas.width / 2, this.canvas.height / 2); + } + + /** + * Set rotations for viewport & tiledImage + * @private + * @param {OpenSeadragon.TiledImage} tiledImage + * @param {Boolean} [useSketch=false] + */ + _setRotations(tiledImage, useSketch = false) { + var saveContext = false; + if (this.viewport.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: this.viewport.getRotation(true), + useSketch: useSketch, + saveContext: saveContext + }); + saveContext = false; + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: this.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + useSketch: useSketch, + saveContext: saveContext + }); + } + } + + // private + _offsetForRotation(options) { + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + + var context = this._getContext(options.useSketch); + context.save(); + + context.translate(point.x, point.y); + context.rotate(Math.PI / 180 * options.degrees); + context.translate(-point.x, -point.y); + } + + // private + _flip(options) { + options = options || {}; + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + var context = this._getContext(options.useSketch); + + context.translate(point.x, 0); + context.scale(-1, 1); + context.translate(-point.x, 0); + } + + // private + _restoreRotationChanges(useSketch) { + var context = this._getContext(useSketch); + context.restore(); + } + + // private + _calculateCanvasSize() { + var pixelDensityRatio = $.pixelDensityRatio; + var viewportSize = this.viewport.getContainerSize(); + return { + // canvas width and height are integers + x: Math.round(viewportSize.x * pixelDensityRatio), + y: Math.round(viewportSize.y * pixelDensityRatio) + }; + } + + // private + _calculateSketchCanvasSize() { + var canvasSize = this._calculateCanvasSize(); + if (this.viewport.getRotation() === 0) { + return canvasSize; + } + // If the viewport is rotated, we need a larger sketch canvas in order + // to support edge smoothing. + var sketchCanvasSize = Math.ceil(Math.sqrt( + canvasSize.x * canvasSize.x + + canvasSize.y * canvasSize.y)); + return { + x: sketchCanvasSize, + y: sketchCanvasSize + }; + } +} +$.CanvasDrawer = CanvasDrawer; + + +/** + * Defines the value for subpixel rounding to fallback to in case of missing or + * invalid value. + * @private + */ +var DEFAULT_SUBPIXEL_ROUNDING_RULE = $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; + +/** + * Checks whether the input value is an invalid subpixel rounding enum value. + * @private + * + * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to check. + * @returns {Boolean} Returns true if the input value is none of the expected + * {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} value. + */ +function isSubPixelRoundingRuleUnknown(value) { + return value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS && + value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST && + value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; +} + +/** + * Ensures the returned value is always a valid subpixel rounding enum value, + * defaulting to {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} if input is missing or invalid. + * @private + * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to normalize. + * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns a valid subpixel rounding enum value. + */ +function normalizeSubPixelRoundingRule(value) { + if (isSubPixelRoundingRuleUnknown(value)) { + return DEFAULT_SUBPIXEL_ROUNDING_RULE; + } + return value; +} + +/** + * Ensures the returned value is always a valid subpixel rounding enum value, + * defaulting to 'NEVER' if input is missing or invalid. + * @private + * + * @param {Object} subPixelRoundingRules - A subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}. + * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns the determined subpixel rounding enum value for the + * current browser. + */ +function determineSubPixelRoundingRule(subPixelRoundingRules) { + if (typeof subPixelRoundingRules === 'number') { + return normalizeSubPixelRoundingRule(subPixelRoundingRules); + } + + if (!subPixelRoundingRules || !$.Browser) { + return DEFAULT_SUBPIXEL_ROUNDING_RULE; + } + + var subPixelRoundingRule = subPixelRoundingRules[$.Browser.vendor]; + + if (isSubPixelRoundingRuleUnknown(subPixelRoundingRule)) { + subPixelRoundingRule = subPixelRoundingRules['*']; + } + + return normalizeSubPixelRoundingRule(subPixelRoundingRule); +} + +}( OpenSeadragon )); + + +/* + * OpenSeadragon - WebGLDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + + const OpenSeadragon = $; // alias for JSDoc + + /** + * @class OpenSeadragon.WebGLDrawer + * @classdesc Default implementation of WebGLDrawer for an {@link OpenSeadragon.Viewer}. The WebGLDrawer + * loads tile data as textures to the graphics card as soon as it is available (via the tile-ready event), + * and unloads the data (via the image-unloaded event). The drawer utilizes a context-dependent two pass drawing pipeline. + * For the first pass, tile composition for a given TiledImage is always done using a canvas with a WebGL context. + * This allows tiles to be stitched together without seams or artifacts, without requiring a tile source with overlap. If overlap is present, + * overlapping pixels are discarded. The second pass copies all pixel data from the WebGL context onto an output canvas + * with a Context2d context. This allows applications to have access to pixel data and other functionality provided by + * Context2d, regardless of whether the CanvasDrawer or the WebGLDrawer is used. Certain options, including compositeOperation, + * clip, croppingPolygons, and debugMode are implemented using Context2d operations; in these scenarios, each TiledImage is + * drawn onto the output canvas immediately after the tile composition step (pass 1). Otherwise, for efficiency, all TiledImages + * are copied over to the output canvas at once, after all tiles have been composited for all images. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + + OpenSeadragon.WebGLDrawer = class WebGLDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (canvas) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.WebGLDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.WebGLDrawer# + */ + + // private members + this._destroyed = false; + this._TextureMap = new Map(); + this._TileMap = new Map(); + + this._gl = null; + this._firstPass = null; + this._secondPass = null; + this._glFrameBuffer = null; + this._renderToTexture = null; + this._glFramebufferToCanvasTransform = null; + this._outputCanvas = null; + this._outputContext = null; + this._clippingCanvas = null; + this._clippingContext = null; + this._renderingCanvas = null; + this._backupCanvasDrawer = null; + + this._imageSmoothingEnabled = true; // will be updated by setImageSmoothingEnabled + + // Add listeners for events that require modifying the scene or camera + this._boundToTileReady = ev => this._tileReadyHandler(ev); + this._boundToImageUnloaded = ev => this._imageUnloadedHandler(ev); + this.viewer.addHandler("tile-ready", this._boundToTileReady); + this.viewer.addHandler("image-unloaded", this._boundToImageUnloaded); + + // Reject listening for the tile-drawing and tile-drawn events, which this drawer does not fire + this.viewer.rejectEventHandler("tile-drawn", "The WebGLDrawer does not raise the tile-drawn event"); + this.viewer.rejectEventHandler("tile-drawing", "The WebGLDrawer does not raise the tile-drawing event"); + + // this.viewer and this.canvas are part of the public DrawerBase API + // and are defined by the parent DrawerBase class. Additional setup is done by + // the private _setupCanvases and _setupRenderer functions. + this._setupCanvases(); + this._setupRenderer(); + + this.context = this._outputContext; // API required by tests + + } + + // Public API required by all Drawer implementations + /** + * Clean up the renderer, removing all resources + */ + destroy(){ + if(this._destroyed){ + return; + } + // clear all resources used by the renderer, geometries, textures etc + let gl = this._gl; + + // adapted from https://stackoverflow.com/a/23606581/1214731 + var numTextureUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS); + for (let unit = 0; unit < numTextureUnits; ++unit) { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindTexture(gl.TEXTURE_CUBE_MAP, null); + } + gl.bindBuffer(gl.ARRAY_BUFFER, null); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this._unloadTextures(); + + // Delete all our created resources + gl.deleteBuffer(this._secondPass.bufferOutputPosition); + gl.deleteFramebuffer(this._glFrameBuffer); + + // make canvases 1 x 1 px and delete references + this._renderingCanvas.width = this._renderingCanvas.height = 1; + this._clippingCanvas.width = this._clippingCanvas.height = 1; + this._outputCanvas.width = this._outputCanvas.height = 1; + this._renderingCanvas = null; + this._clippingCanvas = this._clippingContext = null; + this._outputCanvas = this._outputContext = null; + + let ext = gl.getExtension('WEBGL_lose_context'); + if(ext){ + ext.loseContext(); + } + + // unbind our event listeners from the viewer + this.viewer.removeHandler("tile-ready", this._boundToTileReady); + this.viewer.removeHandler("image-unloaded", this._boundToImageUnloaded); + this.viewer.removeHandler("resize", this._resizeHandler); + + // set our webgl context reference to null to enable garbage collection + this._gl = null; + + if(this._backupCanvasDrawer){ + this._backupCanvasDrawer.destroy(); + this._backupCanvasDrawer = null; + } + + this.container.removeChild(this.canvas); + if(this.viewer.drawer === this){ + this.viewer.drawer = null; + } + + // set our destroyed flag to true + this._destroyed = true; + } + + // Public API required by all Drawer implementations + /** + * + * @returns {Boolean} true + */ + canRotate(){ + return true; + } + + // Public API required by all Drawer implementations + /** + * @returns {Boolean} true if canvas and webgl are supported + */ + static isSupported(){ + let canvasElement = document.createElement( 'canvas' ); + let webglContext = $.isFunction( canvasElement.getContext ) && + canvasElement.getContext( 'webgl' ); + let ext = webglContext && webglContext.getExtension('WEBGL_lose_context'); + if(ext){ + ext.loseContext(); + } + return !!( webglContext ); + } + + /** + * + * @returns 'webgl' + */ + getType(){ + return 'webgl'; + } + + /** + * @param {TiledImage} tiledImage the tiled image that is calling the function + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + * @private + */ + minimumOverlapRequired(tiledImage) { + // return true if the tiled image is tainted, since the backup canvas drawer will be used. + return tiledImage.isTainted(); + } + + /** + * create the HTML element (canvas in this case) that the image will be drawn into + * @private + * @returns {Element} the canvas to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("canvas"); + let viewportSize = this._calculateCanvasSize(); + canvas.width = viewportSize.x; + canvas.height = viewportSize.y; + return canvas; + } + + /** + * Get the backup renderer (CanvasDrawer) to use if data cannot be used by webgl + * Lazy loaded + * @private + * @returns {CanvasDrawer} + */ + _getBackupCanvasDrawer(){ + if(!this._backupCanvasDrawer){ + this._backupCanvasDrawer = this.viewer.requestDrawer('canvas', {mainDrawer: false}); + this._backupCanvasDrawer.canvas.style.setProperty('visibility', 'hidden'); + } + + return this._backupCanvasDrawer; + } + + /** + * + * @param {Array} tiledImages Array of TiledImage objects to draw + */ + draw(tiledImages){ + let gl = this._gl; + const bounds = this.viewport.getBoundsNoRotateWithMargins(true); + let view = { + bounds: bounds, + center: new OpenSeadragon.Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2), + rotation: this.viewport.getRotation(true) * Math.PI / 180 + }; + + let flipMultiplier = this.viewport.flipped ? -1 : 1; + // calculate view matrix for viewer + let posMatrix = $.Mat3.makeTranslation(-view.center.x, -view.center.y); + let scaleMatrix = $.Mat3.makeScaling(2 / view.bounds.width * flipMultiplier, -2 / view.bounds.height); + let rotMatrix = $.Mat3.makeRotation(-view.rotation); + let viewMatrix = scaleMatrix.multiply(rotMatrix).multiply(posMatrix); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + + // clear the output canvas + this._outputContext.clearRect(0, 0, this._outputCanvas.width, this._outputCanvas.height); + + + let renderingBufferHasImageData = false; + + //iterate over tiled images and draw each one using a two-pass rendering pipeline if needed + tiledImages.forEach( (tiledImage, tiledImageIndex) => { + + if(tiledImage.isTainted()){ + // first, draw any data left in the rendering buffer onto the output canvas + if(renderingBufferHasImageData){ + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + // clear the buffer + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + renderingBufferHasImageData = false; + } + + // next, use the backup canvas drawer to draw this tainted image + const canvasDrawer = this._getBackupCanvasDrawer(); + canvasDrawer.draw([tiledImage]); + this._outputContext.drawImage(canvasDrawer.canvas, 0, 0); + + } else { + let tilesToDraw = tiledImage.getTilesToDraw(); + + if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) { + this._drawPlaceholder(tiledImage); + } + + if(tilesToDraw.length === 0 || tiledImage.getOpacity() === 0){ + return; + } + let firstTile = tilesToDraw[0]; + + let useContext2dPipeline = ( tiledImage.compositeOperation || + this.viewer.compositeOperation || + tiledImage._clip || + tiledImage._croppingPolygons || + tiledImage.debugMode + ); + + let useTwoPassRendering = useContext2dPipeline || (tiledImage.opacity < 1) || firstTile.hasTransparency; + + // using the context2d pipeline requires a clean rendering (back) buffer to start + if(useContext2dPipeline){ + // if the rendering buffer has image data currently, write it to the output canvas now and clear it + + if(renderingBufferHasImageData){ + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + + // clear the buffer + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + } + + // First rendering pass: compose tiles that make up this tiledImage + gl.useProgram(this._firstPass.shaderProgram); + + // bind to the framebuffer for render-to-texture if using two-pass rendering, otherwise back buffer (null) + if(useTwoPassRendering){ + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + // clear the buffer to draw a new image + gl.clear(gl.COLOR_BUFFER_BIT); + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // no need to clear, just draw on top of the existing pixels + } + + let overallMatrix = viewMatrix; + + let imageRotation = tiledImage.getRotation(true); + // if needed, handle the tiledImage being rotated + if( imageRotation % 360 !== 0){ + let imageRotationMatrix = $.Mat3.makeRotation(-imageRotation * Math.PI / 180); + let imageCenter = tiledImage.getBoundsNoRotate(true).getCenter(); + let t1 = $.Mat3.makeTranslation(imageCenter.x, imageCenter.y); + let t2 = $.Mat3.makeTranslation(-imageCenter.x, -imageCenter.y); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply(imageRotationMatrix).multiply(t2); + overallMatrix = viewMatrix.multiply(localMatrix); + } + + let maxTextures = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS); + if(maxTextures <= 0){ + // This can apparently happen on some systems if too many WebGL contexts have been created + // in which case maxTextures can be null, leading to out of bounds errors with the array. + // For example, when viewers were created and not destroyed in the test suite, this error + // occurred in the TravisCI tests, though it did not happen when testing locally either in + // a browser or on the command line via grunt test. + + throw(new Error(`WegGL error: bad value for gl parameter MAX_TEXTURE_IMAGE_UNITS (${maxTextures}). This could happen + if too many contexts have been created and not released, or there is another problem with the graphics card.`)); + } + + let texturePositionArray = new Float32Array(maxTextures * 12); // 6 vertices (2 triangles) x 2 coordinates per vertex + let textureDataArray = new Array(maxTextures); + let matrixArray = new Array(maxTextures); + let opacityArray = new Array(maxTextures); + + // iterate over tiles and add data for each one to the buffers + for(let tileIndex = 0; tileIndex < tilesToDraw.length; tileIndex++){ + let tile = tilesToDraw[tileIndex].tile; + let indexInDrawArray = tileIndex % maxTextures; + let numTilesToDraw = indexInDrawArray + 1; + let tileContext = tile.getCanvasContext(); + + let textureInfo = tileContext ? this._TextureMap.get(tileContext.canvas) : null; + if(!textureInfo){ + // tile was not processed in the tile-ready event (this can happen + // if this drawer was created after the tile was downloaded) + this._tileReadyHandler({tile: tile, tiledImage: tiledImage}); + + // retry getting textureInfo + textureInfo = tileContext ? this._TextureMap.get(tileContext.canvas) : null; + } + + if(textureInfo){ + this._getTileData(tile, tiledImage, textureInfo, overallMatrix, indexInDrawArray, texturePositionArray, textureDataArray, matrixArray, opacityArray); + } else { + // console.log('No tile info', tile); + } + if( (numTilesToDraw === maxTextures) || (tileIndex === tilesToDraw.length - 1)){ + // We've filled up the buffers: time to draw this set of tiles + + // bind each tile's texture to the appropriate gl.TEXTURE# + for(let i = 0; i <= numTilesToDraw; i++){ + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, textureDataArray[i]); + } + + // set the buffer data for the texture coordinates to use for each tile + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.bufferData(gl.ARRAY_BUFFER, texturePositionArray, gl.DYNAMIC_DRAW); + + // set the transform matrix uniform for each tile + matrixArray.forEach( (matrix, index) => { + gl.uniformMatrix3fv(this._firstPass.uTransformMatrices[index], false, matrix); + }); + // set the opacity uniform for each tile + gl.uniform1fv(this._firstPass.uOpacities, new Float32Array(opacityArray)); + + // bind vertex buffers and (re)set attributes before calling gl.drawArrays() + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferOutputPosition); + gl.vertexAttribPointer(this._firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.vertexAttribPointer(this._firstPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferIndex); + gl.vertexAttribPointer(this._firstPass.aIndex, 1, gl.FLOAT, false, 0, 0); + + // Draw! 6 vertices per tile (2 triangles per rectangle) + gl.drawArrays(gl.TRIANGLES, 0, 6 * numTilesToDraw ); + } + } + + if(useTwoPassRendering){ + // Second rendering pass: Render the tiled image from the framebuffer into the back buffer + gl.useProgram(this._secondPass.shaderProgram); + + // set the rendering target to the back buffer (null) + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // bind the rendered texture from the first pass to use during this second pass + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + + // set opacity to the value for the current tiledImage + this._gl.uniform1f(this._secondPass.uOpacityMultiplier, tiledImage.opacity); + + // bind buffers and set attributes before calling gl.drawArrays + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferTexturePosition); + gl.vertexAttribPointer(this._secondPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferOutputPosition); + gl.vertexAttribPointer(this._secondPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); + + // Draw the quad (two triangles) + gl.drawArrays(gl.TRIANGLES, 0, 6); + + } + + renderingBufferHasImageData = true; + + if(useContext2dPipeline){ + // draw from the rendering canvas onto the output canvas, clipping/cropping if needed. + this._applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex); + renderingBufferHasImageData = false; + // clear the buffer + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + } + + // after drawing the first TiledImage, fire the tiled-image-drawn event (for testing) + if(tiledImageIndex === 0){ + this._raiseTiledImageDrawnEvent(tiledImage, tilesToDraw.map(info=>info.tile)); + } + } + + + + }); + + if(renderingBufferHasImageData){ + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + + } + + // Public API required by all Drawer implementations + /** + * Sets whether image smoothing is enabled or disabled + * @param {Boolean} enabled If true, uses gl.LINEAR as the TEXTURE_MIN_FILTER and TEXTURE_MAX_FILTER, otherwise gl.NEAREST. + */ + setImageSmoothingEnabled(enabled){ + if( this._imageSmoothingEnabled !== enabled ){ + this._imageSmoothingEnabled = enabled; + this._unloadTextures(); + this.viewer.world.draw(); + } + } + + /** + * Draw a rect onto the output canvas for debugging purposes + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect){ + let context = this._outputContext; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.strokeStyle = this.debugGridColor[0]; + context.fillStyle = this.debugGridColor[0]; + + context.strokeRect( + rect.x * $.pixelDensityRatio, + rect.y * $.pixelDensityRatio, + rect.width * $.pixelDensityRatio, + rect.height * $.pixelDensityRatio + ); + + context.restore(); + } + + // private + _getTextureDataFromTile(tile){ + return tile.getCanvasContext().canvas; + } + + /** + * Draw data from the rendering canvas onto the output canvas, with clipping, + * cropping and/or debug info as requested. + * @private + * @param {OpenSeadragon.TiledImage} tiledImage - the tiledImage to draw + * @param {Array} tilesToDraw - array of objects containing tiles that were drawn + */ + _applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex){ + // composite onto the output canvas, clipping if necessary + this._outputContext.save(); + + // set composite operation; ignore for first image drawn + this._outputContext.globalCompositeOperation = tiledImageIndex === 0 ? null : tiledImage.compositeOperation || this.viewer.compositeOperation; + if(tiledImage._croppingPolygons || tiledImage._clip){ + this._renderToClippingCanvas(tiledImage); + this._outputContext.drawImage(this._clippingCanvas, 0, 0); + + } else { + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + this._outputContext.restore(); + if(tiledImage.debugMode){ + const flipped = this.viewer.viewport.getFlip(); + if(flipped){ + this._flip(); + } + this._drawDebugInfo(tilesToDraw, tiledImage, flipped); + if(flipped){ + this._flip(); + } + } + + + } + + // private + _getTileData(tile, tiledImage, textureInfo, viewMatrix, index, texturePositionArray, textureDataArray, matrixArray, opacityArray){ + + let texture = textureInfo.texture; + let textureQuad = textureInfo.position; + + // set the position of this texture + texturePositionArray.set(textureQuad, index * 12); + + // compute offsets that account for tile overlap; needed for calculating the transform matrix appropriately + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + let xOffset = tile.positionedBounds.width * overlapFraction.x; + let yOffset = tile.positionedBounds.height * overlapFraction.y; + + // x, y, w, h in viewport coords + let x = tile.positionedBounds.x + (tile.x === 0 ? 0 : xOffset); + let y = tile.positionedBounds.y + (tile.y === 0 ? 0 : yOffset); + let right = tile.positionedBounds.x + tile.positionedBounds.width - (tile.isRightMost ? 0 : xOffset); + let bottom = tile.positionedBounds.y + tile.positionedBounds.height - (tile.isBottomMost ? 0 : yOffset); + let w = right - x; + let h = bottom - y; + + let matrix = new $.Mat3([ + w, 0, 0, + 0, h, 0, + x, y, 1, + ]); + + if(tile.flipped){ + // flip the tile around the center of the unit quad + let t1 = $.Mat3.makeTranslation(0.5, 0); + let t2 = $.Mat3.makeTranslation(-0.5, 0); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply($.Mat3.makeScaling(-1, 1)).multiply(t2); + matrix = matrix.multiply(localMatrix); + } + + let overallMatrix = viewMatrix.multiply(matrix); + + opacityArray[index] = tile.opacity; + textureDataArray[index] = texture; + matrixArray[index] = overallMatrix.values; + + } + + // private + _textureFilter(){ + return this._imageSmoothingEnabled ? this._gl.LINEAR : this._gl.NEAREST; + } + + // private + _setupRenderer(){ + let gl = this._gl; + if(!gl){ + $.console.error('_setupCanvases must be called before _setupRenderer'); + } + this._unitQuad = this._makeQuadVertexBuffer(0, 1, 0, 1); // used a few places; create once and store the result + + this._makeFirstPassShaderProgram(); + this._makeSecondPassShaderProgram(); + + // set up the texture to render to in the first pass, and which will be used for rendering the second pass + this._renderToTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this._renderingCanvas.width, this._renderingCanvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // set up the framebuffer for render-to-texture + this._glFrameBuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, // attach texture as COLOR_ATTACHMENT0 + gl.TEXTURE_2D, // attach a 2D texture + this._renderToTexture, // the texture to attach + 0 + ); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + + } + + //private + _makeFirstPassShaderProgram(){ + let numTextures = this._glNumTextures = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS); + let makeMatrixUniforms = () => { + return [...Array(numTextures).keys()].map(index => `uniform mat3 u_matrix_${index};`).join('\n'); + }; + let makeConditionals = () => { + return [...Array(numTextures).keys()].map(index => `${index > 0 ? 'else ' : ''}if(int(a_index) == ${index}) { transform_matrix = u_matrix_${index}; }`).join('\n'); + }; + + const vertexShaderProgram = ` + attribute vec2 a_output_position; + attribute vec2 a_texture_position; + attribute float a_index; + + ${makeMatrixUniforms()} // create a uniform mat3 for each potential tile to draw + + varying vec2 v_texture_position; + varying float v_image_index; + + void main() { + + mat3 transform_matrix; // value will be set by the if/elses in makeConditional() + + ${makeConditionals()} + + gl_Position = vec4(transform_matrix * vec3(a_output_position, 1), 1); + + v_texture_position = a_texture_position; + v_image_index = a_index; + } + `; + + const fragmentShaderProgram = ` + precision mediump float; + + // our textures + uniform sampler2D u_images[${numTextures}]; + // our opacities + uniform float u_opacities[${numTextures}]; + + // the varyings passed in from the vertex shader. + varying vec2 v_texture_position; + varying float v_image_index; + + void main() { + // can't index directly with a variable, need to use a loop iterator hack + for(int i = 0; i < ${numTextures}; ++i){ + if(i == int(v_image_index)){ + gl_FragColor = texture2D(u_images[i], v_texture_position) * u_opacities[i]; + } + } + } + `; + + let gl = this._gl; + + let program = this.constructor.initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram); + gl.useProgram(program); + + // get locations of attributes and uniforms, and create buffers for each attribute + this._firstPass = { + shaderProgram: program, + aOutputPosition: gl.getAttribLocation(program, 'a_output_position'), + aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'), + aIndex: gl.getAttribLocation(program, 'a_index'), + uTransformMatrices: [...Array(this._glNumTextures).keys()].map(i=>gl.getUniformLocation(program, `u_matrix_${i}`)), + uImages: gl.getUniformLocation(program, 'u_images'), + uOpacities: gl.getUniformLocation(program, 'u_opacities'), + bufferOutputPosition: gl.createBuffer(), + bufferTexturePosition: gl.createBuffer(), + bufferIndex: gl.createBuffer(), + }; + + gl.uniform1iv(this._firstPass.uImages, [...Array(numTextures).keys()]); + + // provide coordinates for the rectangle in output space, i.e. a unit quad for each one. + let outputQuads = new Float32Array(numTextures * 12); + for(let i = 0; i < numTextures; ++i){ + outputQuads.set(Float32Array.from(this._unitQuad), i * 12); + } + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferOutputPosition); + gl.bufferData(gl.ARRAY_BUFFER, outputQuads, gl.STATIC_DRAW); // bind data statically here, since it's unchanging + gl.enableVertexAttribArray(this._firstPass.aOutputPosition); + + // provide texture coordinates for the rectangle in image (texture) space. Data will be set later. + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.enableVertexAttribArray(this._firstPass.aTexturePosition); + + // for each vertex, provide an index into the array of textures/matrices to use for the correct tile + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferIndex); + let indices = [...Array(this._glNumTextures).keys()].map(i => Array(6).fill(i)).flat(); // repeat each index 6 times, for the 6 vertices per tile (2 triangles) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(indices), gl.STATIC_DRAW); // bind data statically here, since it's unchanging + gl.enableVertexAttribArray(this._firstPass.aIndex); + + } + + // private + _makeSecondPassShaderProgram(){ + const vertexShaderProgram = ` + attribute vec2 a_output_position; + attribute vec2 a_texture_position; + + uniform mat3 u_matrix; + + varying vec2 v_texture_position; + + void main() { + gl_Position = vec4(u_matrix * vec3(a_output_position, 1), 1); + + v_texture_position = a_texture_position; + } + `; + + const fragmentShaderProgram = ` + precision mediump float; + + // our texture + uniform sampler2D u_image; + + // the texCoords passed in from the vertex shader. + varying vec2 v_texture_position; + + // the opacity multiplier for the image + uniform float u_opacity_multiplier; + + void main() { + gl_FragColor = texture2D(u_image, v_texture_position); + gl_FragColor *= u_opacity_multiplier; + } + `; + + let gl = this._gl; + + let program = this.constructor.initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram); + gl.useProgram(program); + + // get locations of attributes and uniforms, and create buffers for each attribute + this._secondPass = { + shaderProgram: program, + aOutputPosition: gl.getAttribLocation(program, 'a_output_position'), + aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'), + uMatrix: gl.getUniformLocation(program, 'u_matrix'), + uImage: gl.getUniformLocation(program, 'u_image'), + uOpacityMultiplier: gl.getUniformLocation(program, 'u_opacity_multiplier'), + bufferOutputPosition: gl.createBuffer(), + bufferTexturePosition: gl.createBuffer(), }; + + + // provide coordinates for the rectangle in output space, i.e. a unit quad for each one. + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferOutputPosition); + gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.STATIC_DRAW); // bind data statically here since it's unchanging + gl.enableVertexAttribArray(this._secondPass.aOutputPosition); + + // provide texture coordinates for the rectangle in image (texture) space. + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferTexturePosition); + gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.DYNAMIC_DRAW); // bind data statically here since it's unchanging + gl.enableVertexAttribArray(this._secondPass.aTexturePosition); + + // set the matrix that transforms the framebuffer to clip space + let matrix = $.Mat3.makeScaling(2, 2).multiply($.Mat3.makeTranslation(-0.5, -0.5)); + gl.uniformMatrix3fv(this._secondPass.uMatrix, false, matrix.values); } - if (!this.useCanvas || !this.sketchCanvas) { - return; + + // private + _resizeRenderer(){ + let gl = this._gl; + let w = this._renderingCanvas.width; + let h = this._renderingCanvas.height; + gl.viewport(0, 0, w, h); + + //release the old texture + gl.deleteTexture(this._renderToTexture); + //create a new texture and set it up + this._renderToTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + //bind the frame buffer to the new texture + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._renderToTexture, 0); + } - opacity = options.opacity; - compositeOperation = options.compositeOperation; - var bounds = options.bounds; - this.context.save(); - this.context.globalAlpha = opacity; - if (compositeOperation) { - this.context.globalCompositeOperation = compositeOperation; + // private + _setupCanvases(){ + let _this = this; + + this._outputCanvas = this.canvas; //output canvas + this._outputContext = this._outputCanvas.getContext('2d'); + + this._renderingCanvas = document.createElement('canvas'); + + this._clippingCanvas = document.createElement('canvas'); + this._clippingContext = this._clippingCanvas.getContext('2d'); + this._renderingCanvas.width = this._clippingCanvas.width = this._outputCanvas.width; + this._renderingCanvas.height = this._clippingCanvas.height = this._outputCanvas.height; + + this._gl = this._renderingCanvas.getContext('webgl'); + + this._resizeHandler = function(){ + + if(_this._outputCanvas !== _this.viewer.drawer.canvas){ + _this._outputCanvas.style.width = _this.viewer.drawer.canvas.clientWidth + 'px'; + _this._outputCanvas.style.height = _this.viewer.drawer.canvas.clientHeight + 'px'; + } + + let viewportSize = _this._calculateCanvasSize(); + if( _this._outputCanvas.width !== viewportSize.x || + _this._outputCanvas.height !== viewportSize.y ) { + _this._outputCanvas.width = viewportSize.x; + _this._outputCanvas.height = viewportSize.y; + } + + _this._renderingCanvas.style.width = _this._outputCanvas.clientWidth + 'px'; + _this._renderingCanvas.style.height = _this._outputCanvas.clientHeight + 'px'; + _this._renderingCanvas.width = _this._clippingCanvas.width = _this._outputCanvas.width; + _this._renderingCanvas.height = _this._clippingCanvas.height = _this._outputCanvas.height; + + // important - update the size of the rendering viewport! + _this._resizeRenderer(); + }; + + //make the additional canvas elements mirror size changes to the output canvas + this.viewer.addHandler("resize", this._resizeHandler); } - if (bounds) { - // Internet Explorer, Microsoft Edge, and Safari have problems - // when you call context.drawImage with negative x or y - // or x + width or y + height greater than the canvas width or height respectively. - if (bounds.x < 0) { - bounds.width += bounds.x; - bounds.x = 0; + + // private + _makeQuadVertexBuffer(left, right, top, bottom){ + return new Float32Array([ + left, bottom, + right, bottom, + left, top, + left, top, + right, bottom, + right, top]); + } + + // private + _tileReadyHandler(event){ + let tile = event.tile; + let tiledImage = event.tiledImage; + + // If a tiledImage is already known to be tainted, don't try to upload any + // textures to webgl, because they won't be used even if it succeeds + if(tiledImage.isTainted()){ + return; } - if (bounds.x + bounds.width > this.canvas.width) { - bounds.width = this.canvas.width - bounds.x; + + let tileContext = tile.getCanvasContext(); + let canvas = tileContext && tileContext.canvas; + // if the tile doesn't provide a canvas, or is tainted by cross-origin + // data, marked the TiledImage as tainted so the canvas drawer can be + // used instead, and return immediately - tainted data cannot be uploaded to webgl + if(!canvas || $.isCanvasTainted(canvas)){ + const wasTainted = tiledImage.isTainted(); + if(!wasTainted){ + tiledImage.setTainted(true); + $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?'); + this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.'); + } + return; } - if (bounds.y < 0) { - bounds.height += bounds.y; - bounds.y = 0; + + let textureInfo = this._TextureMap.get(canvas); + + // if this is a new image for us, create a texture + if(!textureInfo){ + let gl = this._gl; + + // create a gl Texture for this tile and bind the canvas with the image data + let texture = gl.createTexture(); + let position; + let overlap = tiledImage.source.tileOverlap; + + // deal with tiles where there is padding, i.e. the pixel data doesn't take up the entire provided canvas + let sourceWidthFraction, sourceHeightFraction; + if (tile.sourceBounds) { + sourceWidthFraction = Math.min(tile.sourceBounds.width, canvas.width) / canvas.width; + sourceHeightFraction = Math.min(tile.sourceBounds.height, canvas.height) / canvas.height; + } else { + sourceWidthFraction = 1; + sourceHeightFraction = 1; + } + + if( overlap > 0){ + // calculate the normalized position of the rect to actually draw + // discarding overlap. + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + + let left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction; + let top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction; + let right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction; + let bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction; + position = this._makeQuadVertexBuffer(left, right, top, bottom); + } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) { + // no overlap and no padding: this texture can use the unit quad as its position data + position = this._unitQuad; + } else { + position = this._makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction); + } + + let textureInfo = { + texture: texture, + position: position, + }; + + // add it to our _TextureMap + this._TextureMap.set(canvas, textureInfo); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + // Set the parameters so we can render any size image. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this._textureFilter()); + + // Upload the image into the texture. + this._uploadImageData(tileContext); + } - if (bounds.y + bounds.height > this.canvas.height) { - bounds.height = this.canvas.height - bounds.y; + + } + + // private + _calculateOverlapFraction(tile, tiledImage){ + let overlap = tiledImage.source.tileOverlap; + let nativeWidth = tile.sourceBounds.width; // in pixels + let nativeHeight = tile.sourceBounds.height; // in pixels + let overlapWidth = (tile.x === 0 ? 0 : overlap) + (tile.isRightMost ? 0 : overlap); // in pixels + let overlapHeight = (tile.y === 0 ? 0 : overlap) + (tile.isBottomMost ? 0 : overlap); // in pixels + let widthOverlapFraction = overlap / (nativeWidth + overlapWidth); // as a fraction of image including overlap + let heightOverlapFraction = overlap / (nativeHeight + overlapHeight); // as a fraction of image including overlap + return { + x: widthOverlapFraction, + y: heightOverlapFraction + }; + } + + // private + _unloadTextures(){ + let canvases = Array.from(this._TextureMap.keys()); + canvases.forEach(canvas => { + this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap + }); + } + + // private + _uploadImageData(tileContext){ + + let gl = this._gl; + let canvas = tileContext.canvas; + + try{ + if(!canvas){ + throw('Tile context does not have a canvas', tileContext); + } + // This depends on gl.TEXTURE_2D being bound to the texture + // associated with this canvas before calling this function + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + } catch (e){ + $.console.error('Error uploading image data to WebGL', e); } + } - this.context.drawImage( - this.sketchCanvas, - bounds.x, - bounds.y, - bounds.width, - bounds.height, - bounds.x, - bounds.y, - bounds.width, - bounds.height - ); - } else { - scale = options.scale || 1; - translate = options.translate; - var position = translate instanceof $.Point ? - translate : new $.Point(0, 0); + // private + _imageUnloadedHandler(event){ + let canvas = event.context2D.canvas; + this._cleanupImageData(canvas); + } - var widthExt = 0; - var heightExt = 0; - if (translate) { - var widthDiff = this.sketchCanvas.width - this.canvas.width; - var heightDiff = this.sketchCanvas.height - this.canvas.height; - widthExt = Math.round(widthDiff / 2); - heightExt = Math.round(heightDiff / 2); + // private + _cleanupImageData(tileCanvas){ + let textureInfo = this._TextureMap.get(tileCanvas); + //remove from the map + this._TextureMap.delete(tileCanvas); + + //release the texture from the GPU + if(textureInfo){ + this._gl.deleteTexture(textureInfo.texture); } - this.context.drawImage( - this.sketchCanvas, - position.x - widthExt * scale, - position.y - heightExt * scale, - (this.canvas.width + 2 * widthExt) * scale, - (this.canvas.height + 2 * heightExt) * scale, - -widthExt, - -heightExt, - this.canvas.width + 2 * widthExt, - this.canvas.height + 2 * heightExt - ); + } - this.context.restore(); - }, - // private - drawDebugInfo: function(tile, count, i, tiledImage) { - if ( !this.useCanvas ) { - return; + // private + _setClip(){ + // no-op: called by _renderToClippingCanvas when tiledImage._clip is truthy + // so that tests will pass. } - var colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; - var context = this.context; - context.save(); - context.lineWidth = 2 * $.pixelDensityRatio; - context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; - context.strokeStyle = this.debugGridColor[colorIndex]; - context.fillStyle = this.debugGridColor[colorIndex]; + // private + _renderToClippingCanvas(item){ + + this._clippingContext.clearRect(0, 0, this._clippingCanvas.width, this._clippingCanvas.height); + this._clippingContext.save(); + if(this.viewer.viewport.getFlip()){ + const point = new $.Point(this.canvas.width / 2, this.canvas.height / 2); + this._clippingContext.translate(point.x, 0); + this._clippingContext.scale(-1, 1); + this._clippingContext.translate(-point.x, 0); + } + + if(item._clip){ + const polygon = [ + {x: item._clip.x, y: item._clip.y}, + {x: item._clip.x + item._clip.width, y: item._clip.y}, + {x: item._clip.x + item._clip.width, y: item._clip.y + item._clip.height}, + {x: item._clip.x, y: item._clip.y + item._clip.height}, + ]; + let clipPoints = polygon.map(coord => { + let point = item.imageToViewportCoordinates(coord.x, coord.y, true) + .rotate(this.viewer.viewport.getRotation(true), this.viewer.viewport.getCenter(true)); + let clipPoint = this.viewportCoordToDrawerCoord(point); + return clipPoint; + }); + this._clippingContext.beginPath(); + clipPoints.forEach( (coord, i) => { + this._clippingContext[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); + }); + this._clippingContext.clip(); + this._setClip(); + } + if(item._croppingPolygons){ + let polygons = item._croppingPolygons.map(polygon => { + return polygon.map(coord => { + let point = item.imageToViewportCoordinates(coord.x, coord.y, true) + .rotate(this.viewer.viewport.getRotation(true), this.viewer.viewport.getCenter(true)); + let clipPoint = this.viewportCoordToDrawerCoord(point); + return clipPoint; + }); + }); + this._clippingContext.beginPath(); + polygons.forEach((polygon) => { + polygon.forEach( (coord, i) => { + this._clippingContext[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); + }); + }); + this._clippingContext.clip(); + } - if (this.viewport.getRotation(true) % 360 !== 0 ) { - this._offsetForRotation({degrees: this.viewport.getRotation(true)}); + if(this.viewer.viewport.getFlip()){ + const point = new $.Point(this.canvas.width / 2, this.canvas.height / 2); + this._clippingContext.translate(point.x, 0); + this._clippingContext.scale(-1, 1); + this._clippingContext.translate(-point.x, 0); + } + + this._clippingContext.drawImage(this._renderingCanvas, 0, 0); + + this._clippingContext.restore(); } - if (tiledImage.getRotation(true) % 360 !== 0) { - this._offsetForRotation({ - degrees: tiledImage.getRotation(true), - point: tiledImage.viewport.pixelFromPointNoRotate( - tiledImage._getRotationPoint(true), true) - }); + + /** + * Set rotations for viewport & tiledImage + * @private + * @param {OpenSeadragon.TiledImage} tiledImage + */ + _setRotations(tiledImage) { + var saveContext = false; + if (this.viewport.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: this.viewport.getRotation(true), + saveContext: saveContext + }); + saveContext = false; + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: this.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + saveContext: saveContext + }); + } + } + + // private + _offsetForRotation(options) { + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + + var context = this._outputContext; + context.save(); + + context.translate(point.x, point.y); + context.rotate(Math.PI / 180 * options.degrees); + context.translate(-point.x, -point.y); } - if (tiledImage.viewport.getRotation(true) % 360 === 0 && - tiledImage.getRotation(true) % 360 === 0) { - if(tiledImage._drawer.viewer.viewport.getFlip()) { - tiledImage._drawer._flip(); + + // private + _flip(options) { + options = options || {}; + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + var context = this._outputContext; + + context.translate(point.x, 0); + context.scale(-1, 1); + context.translate(-point.x, 0); + } + + // private + _drawDebugInfo( tilesToDraw, tiledImage, flipped ) { + + for ( var i = tilesToDraw.length - 1; i >= 0; i-- ) { + var tile = tilesToDraw[ i ].tile; + try { + this._drawDebugInfoOnTile(tile, tilesToDraw.length, i, tiledImage, flipped); + } catch(e) { + $.console.error(e); + } } } - context.strokeRect( - tile.position.x * $.pixelDensityRatio, - tile.position.y * $.pixelDensityRatio, - tile.size.x * $.pixelDensityRatio, - tile.size.y * $.pixelDensityRatio - ); + // private + _drawDebugInfoOnTile(tile, count, i, tiledImage, flipped) { - var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; - var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; + var colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; + var context = this.context; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; + context.strokeStyle = this.debugGridColor[colorIndex]; + context.fillStyle = this.debugGridColor[colorIndex]; - // Rotate the text the right way around. - context.translate( tileCenterX, tileCenterY ); - context.rotate( Math.PI / 180 * -this.viewport.getRotation(true) ); - context.translate( -tileCenterX, -tileCenterY ); + this._setRotations(tiledImage); - if( tile.x === 0 && tile.y === 0 ){ - context.fillText( - "Zoom: " + this.viewport.getZoom(), + if(flipped){ + this._flip({point: tile.position.plus(tile.size.divide(2))}); + } + + context.strokeRect( tile.position.x * $.pixelDensityRatio, - (tile.position.y - 30) * $.pixelDensityRatio + tile.position.y * $.pixelDensityRatio, + tile.size.x * $.pixelDensityRatio, + tile.size.y * $.pixelDensityRatio ); + + var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; + var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; + + // Rotate the text the right way around. + context.translate( tileCenterX, tileCenterY ); + const angleInDegrees = this.viewport.getRotation(true); + context.rotate( Math.PI / 180 * -angleInDegrees ); + context.translate( -tileCenterX, -tileCenterY ); + + if( tile.x === 0 && tile.y === 0 ){ + context.fillText( + "Zoom: " + this.viewport.getZoom(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 30) * $.pixelDensityRatio + ); + context.fillText( + "Pan: " + this.viewport.getBounds().toString(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 20) * $.pixelDensityRatio + ); + } context.fillText( - "Pan: " + this.viewport.getBounds().toString(), - tile.position.x * $.pixelDensityRatio, - (tile.position.y - 20) * $.pixelDensityRatio + "Level: " + tile.level, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 20) * $.pixelDensityRatio + ); + context.fillText( + "Column: " + tile.x, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 30) * $.pixelDensityRatio + ); + context.fillText( + "Row: " + tile.y, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 40) * $.pixelDensityRatio + ); + context.fillText( + "Order: " + i + " of " + count, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 50) * $.pixelDensityRatio + ); + context.fillText( + "Size: " + tile.size.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 60) * $.pixelDensityRatio + ); + context.fillText( + "Position: " + tile.position.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 70) * $.pixelDensityRatio ); - } - context.fillText( - "Level: " + tile.level, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 20) * $.pixelDensityRatio - ); - context.fillText( - "Column: " + tile.x, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 30) * $.pixelDensityRatio - ); - context.fillText( - "Row: " + tile.y, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 40) * $.pixelDensityRatio - ); - context.fillText( - "Order: " + i + " of " + count, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 50) * $.pixelDensityRatio - ); - context.fillText( - "Size: " + tile.size.toString(), - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 60) * $.pixelDensityRatio - ); - context.fillText( - "Position: " + tile.position.toString(), - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 70) * $.pixelDensityRatio - ); - if (this.viewport.getRotation(true) % 360 !== 0 ) { - this._restoreRotationChanges(); - } - if (tiledImage.getRotation(true) % 360 !== 0) { - this._restoreRotationChanges(); + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._restoreRotationChanges(); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(); + } + + context.restore(); } - if (tiledImage.viewport.getRotation(true) % 360 === 0 && - tiledImage.getRotation(true) % 360 === 0) { - if(tiledImage._drawer.viewer.viewport.getFlip()) { - tiledImage._drawer._flip(); + _drawPlaceholder(tiledImage){ + + const bounds = tiledImage.getBounds(true); + const rect = this.viewportToDrawerRectangle(tiledImage.getBounds(true)); + const context = this._outputContext; + + let fillStyle; + if ( typeof tiledImage.placeholderFillStyle === "function" ) { + fillStyle = tiledImage.placeholderFillStyle(tiledImage, context); + } + else { + fillStyle = tiledImage.placeholderFillStyle; } - } - context.restore(); - }, + this._offsetForRotation({degrees: this.viewer.viewport.getRotation(true)}); + context.fillStyle = fillStyle; + context.translate(rect.x, rect.y); + context.rotate(Math.PI / 180 * bounds.degrees); + context.translate(-rect.x, -rect.y); + context.fillRect(rect.x, rect.y, rect.width, rect.height); + this._restoreRotationChanges(); - // private - debugRect: function(rect) { - if ( this.useCanvas ) { - var context = this.context; - context.save(); - context.lineWidth = 2 * $.pixelDensityRatio; - context.strokeStyle = this.debugGridColor[0]; - context.fillStyle = this.debugGridColor[0]; + } - context.strokeRect( - rect.x * $.pixelDensityRatio, - rect.y * $.pixelDensityRatio, - rect.width * $.pixelDensityRatio, - rect.height * $.pixelDensityRatio - ); + /** + * Get the canvas center + * @private + * @returns {OpenSeadragon.Point} The center point of the canvas + */ + _getCanvasCenter() { + return new $.Point(this.canvas.width / 2, this.canvas.height / 2); + } + // private + _restoreRotationChanges() { + var context = this._outputContext; context.restore(); } - }, - /** - * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property. - * - * @function - * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is - * drawn smoothly on the canvas; see imageSmoothingEnabled in - * {@link OpenSeadragon.Options} for more explanation. - */ - setImageSmoothingEnabled: function(imageSmoothingEnabled){ - if ( this.useCanvas ) { - this._imageSmoothingEnabled = imageSmoothingEnabled; - this._updateImageSmoothingEnabled(this.context); - this.viewer.forceRedraw(); - } - }, + // modified from https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Adding_2D_content_to_a_WebGL_context + static initShaderProgram(gl, vsSource, fsSource) { - // private - _updateImageSmoothingEnabled: function(context){ - context.msImageSmoothingEnabled = this._imageSmoothingEnabled; - context.imageSmoothingEnabled = this._imageSmoothingEnabled; - }, + function loadShader(gl, type, source) { + const shader = gl.createShader(type); - /** - * Get the canvas size - * @param {Boolean} sketch If set to true return the size of the sketch canvas - * @returns {OpenSeadragon.Point} The size of the canvas - */ - getCanvasSize: function(sketch) { - var canvas = this._getContext(sketch).canvas; - return new $.Point(canvas.width, canvas.height); - }, + // Send the source to the shader object - getCanvasCenter: function() { - return new $.Point(this.canvas.width / 2, this.canvas.height / 2); - }, + gl.shaderSource(shader, source); - // private - _offsetForRotation: function(options) { - var point = options.point ? - options.point.times($.pixelDensityRatio) : - this.getCanvasCenter(); + // Compile the shader program - var context = this._getContext(options.useSketch); - context.save(); + gl.compileShader(shader); - context.translate(point.x, point.y); - if(this.viewer.viewport.flipped){ - context.rotate(Math.PI / 180 * -options.degrees); - context.scale(-1, 1); - } else{ - context.rotate(Math.PI / 180 * options.degrees); - } - context.translate(-point.x, -point.y); - }, + // See if it compiled successfully - // private - _flip: function(options) { - options = options || {}; - var point = options.point ? - options.point.times($.pixelDensityRatio) : - this.getCanvasCenter(); - var context = this._getContext(options.useSketch); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + $.console.error( + `An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}` + ); + gl.deleteShader(shader); + return null; + } - context.translate(point.x, 0); - context.scale(-1, 1); - context.translate(-point.x, 0); - }, + return shader; + } - // private - _restoreRotationChanges: function(useSketch) { - var context = this._getContext(useSketch); - context.restore(); - }, + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); - // private - _calculateCanvasSize: function() { - var pixelDensityRatio = $.pixelDensityRatio; - var viewportSize = this.viewport.getContainerSize(); - return { - // canvas width and height are integers - x: Math.round(viewportSize.x * pixelDensityRatio), - y: Math.round(viewportSize.y * pixelDensityRatio) - }; - }, + // Create the shader program - // private - _calculateSketchCanvasSize: function() { - var canvasSize = this._calculateCanvasSize(); - if (this.viewport.getRotation() === 0) { - return canvasSize; + const shaderProgram = gl.createProgram(); + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + // If creating the shader program failed, alert + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + $.console.error( + `Unable to initialize the shader program: ${gl.getProgramInfoLog( + shaderProgram + )}` + ); + return null; + } + + return shaderProgram; } - // If the viewport is rotated, we need a larger sketch canvas in order - // to support edge smoothing. - var sketchCanvasSize = Math.ceil(Math.sqrt( - canvasSize.x * canvasSize.x + - canvasSize.y * canvasSize.y)); - return { - x: sketchCanvasSize, - y: sketchCanvasSize - }; - } -}; + + }; + + }( OpenSeadragon )); @@ -19935,7 +22419,7 @@ $.Drawer.prototype = { * OpenSeadragon - Viewport * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -21066,7 +23550,7 @@ $.Viewport.prototype = { /** * Update the zoom, degrees, and center (X and Y) springs. * @function - * @returns {Boolean} True if any change has been made, false otherwise. + * @returns {Boolean} True if the viewport is still animating, false otherwise. */ update: function() { var _this = this; @@ -21098,7 +23582,13 @@ $.Viewport.prototype = { this._oldZoom = this.zoomSpring.current.value; this._oldDegrees = this.degreesSpring.current.value; - return changed; + var isAnimating = changed || + !this.zoomSpring.isAtTargetValue() || + !this.centerSpringX.isAtTargetValue() || + !this.centerSpringY.isAtTargetValue() || + !this.degreesSpring.isAtTargetValue(); + + return isAnimating; }, // private - pass true to use spring, or a number for degrees for immediate rotation @@ -21638,7 +24128,7 @@ $.Viewport.prototype = { * 1 means original image size, 0.5 half size... * Viewport zoom: ratio of the displayed image's width to viewport's width. * 1 means identical width, 2 means image's width is twice the viewport's width... - * Note: not accurate with multi-image. + * Note: not accurate with multi-image; use [TiledImage.imageToViewportZoom] for the specific image of interest. * @function * @param {Number} imageZoom The image zoom * target zoom. @@ -21650,7 +24140,7 @@ $.Viewport.prototype = { if (count > 1) { if (!this.silenceMultiImageWarnings) { $.console.error('[Viewport.imageToViewportZoom] is not accurate ' + - 'with multi-image.'); + 'with multi-image. Instead, use [TiledImage.imageToViewportZoom] for the specific image of interest'); } } else if (count === 1) { // It is better to use TiledImage.imageToViewportZoom @@ -21716,7 +24206,41 @@ $.Viewport.prototype = { */ this.viewer.raiseEvent('flip', {flipped: state}); return this; - } + }, + + /** + * Gets current max zoom pixel ratio + * @function + * @returns {Number} Max zoom pixel ratio + */ + getMaxZoomPixelRatio: function() { + return this.maxZoomPixelRatio; + }, + + /** + * Sets max zoom pixel ratio + * @function + * @param {Number} ratio - Max zoom pixel ratio + * @param {Boolean} [applyConstraints=true] - Apply constraints after setting ratio; + * Takes effect only if current zoom is greater than set max zoom pixel ratio + * @param {Boolean} [immediately=false] - Whether to animate to new zoom + */ + setMaxZoomPixelRatio: function(ratio, applyConstraints = true, immediately = false) { + + $.console.assert(!isNaN(ratio), "[Viewport.setMaxZoomPixelRatio] ratio must be a number"); + + if (isNaN(ratio)) { + return; + } + + this.maxZoomPixelRatio = ratio; + + if (applyConstraints) { + if (this.getZoom() > this.getMaxZoom()) { + this.applyConstraints(immediately); + } + } + }, }; @@ -21726,7 +24250,7 @@ $.Viewport.prototype = { * OpenSeadragon - TiledImage * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -21796,8 +24320,8 @@ $.Viewport.prototype = { * @param {Boolean} [options.iOSDevice] - See {@link OpenSeadragon.Options}. * @param {Number} [options.opacity=1] - Set to draw at proportional opacity. If zero, images will not draw. * @param {Boolean} [options.preload=false] - Set true to load even when the image is hidden by zero opacity. - * @param {String} [options.compositeOperation] - How the image is composited onto other images; see compositeOperation in {@link OpenSeadragon.Options} for possible - values. + * @param {String} [options.compositeOperation] - How the image is composited onto other images; + * see compositeOperation in {@link OpenSeadragon.Options} for possible values. * @param {Boolean} [options.debugMode] - See {@link OpenSeadragon.Options}. * @param {String|CanvasGradient|CanvasPattern|Function} [options.placeholderFillStyle] - See {@link OpenSeadragon.Options}. * @param {String|Boolean} [options.crossOriginPolicy] - See {@link OpenSeadragon.Options}. @@ -21809,7 +24333,7 @@ $.Viewport.prototype = { * A set of headers to include when making tile AJAX requests. */ $.TiledImage = function( options ) { - var _this = this; + this._initialized = false; /** * The {@link OpenSeadragon.TileSource} that defines this TiledImage. * @member {OpenSeadragon.TileSource} source @@ -21883,10 +24407,15 @@ $.TiledImage = function( options ) { loadingCoverage: {}, // A '3d' dictionary [level][x][y] --> Boolean; shows what areas are loaded or are being loaded/blended. lastDrawn: [], // An unordered list of Tiles drawn last frame. lastResetTime: 0, // Last time for which the tiledImage was reset. - _midDraw: false, // Is the tiledImage currently updating the viewport? - _needsDraw: true, // Does the tiledImage need to update the viewport again? + _needsDraw: true, // Does the tiledImage need to be drawn again? + _needsUpdate: true, // Does the tiledImage need to update the viewport again? _hasOpaqueTile: false, // Do we have even one fully opaque tile? _tilesLoading: 0, // The number of pending tile requests. + _tilesToDraw: [], // info about the tiles currently in the viewport, two deep: array[level][tile] + _lastDrawn: [], // array of tiles that were last fetched by the drawer + _isBlending: false, // Are any tiles still being blended? + _wasBlending: false, // Were any tiles blending before the last draw? + _isTainted: false, // Has a Tile been found with tainted data? //configurable settings springStiffness: $.DEFAULT_SETTINGS.springStiffness, animationTime: $.DEFAULT_SETTINGS.animationTime, @@ -21906,7 +24435,8 @@ $.TiledImage = function( options ) { opacity: $.DEFAULT_SETTINGS.opacity, preload: $.DEFAULT_SETTINGS.preload, compositeOperation: $.DEFAULT_SETTINGS.compositeOperation, - subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency + subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency, + maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame }, options ); this._preload = this.preload; @@ -21944,30 +24474,9 @@ $.TiledImage = function( options ) { this.fitBounds(fitBounds, fitBoundsPlacement, true); } - // We need a callback to give image manipulation a chance to happen - this._drawingHandler = function(args) { - /** - * This event is fired just before the tile is drawn giving the application a chance to alter the image. - * - * NOTE: This event is only fired when the drawer is using a <canvas>. - * - * @event tile-drawing - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.Tile} tile - The Tile being drawn. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {OpenSeadragon.Tile} context - The HTML canvas context being drawn into. - * @property {OpenSeadragon.Tile} rendered - The HTML canvas context containing the tile imagery. - * @property {?Object} userData - Arbitrary subscriber-defined object. - */ - _this.viewer.raiseEvent('tile-drawing', $.extend({ - tiledImage: _this - }, args)); - }; - this._ownAjaxHeaders = {}; this.setAjaxHeaders(ajaxHeaders, false); + this._initialized = true; }; $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.TiledImage.prototype */{ @@ -21978,6 +24487,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return this._needsDraw; }, + /** + * Mark the tiled image as needing to be (re)drawn + */ + redraw: function() { + this._needsDraw = true; + }, + /** * @returns {Boolean} Whether all tiles necessary for this TiledImage to draw at the current view have been loaded. */ @@ -22020,17 +24536,29 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * Updates the TiledImage's bounds, animating if needed. - * @returns {Boolean} Whether the TiledImage animated. + * Updates the TiledImage's bounds, animating if needed. Based on the new + * bounds, updates the levels and tiles to be drawn into the viewport. + * @param viewportChanged Whether the viewport changed meaning tiles need to be updated. + * @returns {Boolean} Whether the TiledImage needs to be drawn. */ - update: function() { - var xUpdated = this._xSpring.update(); - var yUpdated = this._ySpring.update(); - var scaleUpdated = this._scaleSpring.update(); - var degreesUpdated = this._degreesSpring.update(); + update: function(viewportChanged) { + let xUpdated = this._xSpring.update(); + let yUpdated = this._ySpring.update(); + let scaleUpdated = this._scaleSpring.update(); + let degreesUpdated = this._degreesSpring.update(); + + let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate); + + if (updated || viewportChanged || !this._fullyLoaded){ + let fullyLoadedFlag = this._updateLevelsForViewport(); + this._setFullyLoaded(fullyLoadedFlag); + } + + this._needsUpdate = false; - if (xUpdated || yUpdated || scaleUpdated || degreesUpdated) { + if (updated) { this._updateForScale(); + this._raiseBoundsChange(); this._needsDraw = true; return true; } @@ -22039,18 +24567,33 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * Draws the TiledImage to its Drawer. + * Mark this TiledImage as having been drawn, so that it will only be drawn + * again if something changes about the image. If the image is still blending, + * this will have no effect. + * @returns {Boolean} whether the item still needs to be drawn due to blending */ - draw: function() { - if (this.opacity !== 0 || this._preload) { - this._midDraw = true; - this._updateViewport(); - this._midDraw = false; - } - // Images with opacity 0 should not need to be drawn in future. this._needsDraw = false is set in this._updateViewport() for other images. - else { - this._needsDraw = false; - } + setDrawn: function(){ + this._needsDraw = this._isBlending || this._wasBlending; + return this._needsDraw; + }, + + /** + * Set the internal _isTainted flag for this TiledImage. Lazy loaded - not + * checked each time a Tile is loaded, but can be set if a consumer of the + * tiles (e.g. a Drawer) discovers a Tile to have tainted data so that further + * checks are not needed and alternative rendering strategies can be used. + * @private + */ + setTainted(isTainted){ + this._isTainted = isTainted; + }, + + /** + * @private + * @returns {Boolean} whether the TiledImage has been marked as tainted + */ + isTainted(){ + return this._isTainted; }, /** @@ -22060,7 +24603,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this.reset(); if (this.source.destroy) { - this.source.destroy(); + this.source.destroy(this.viewer); } }, @@ -22137,7 +24680,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag var yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y; var bounds = this.source.getTileBounds(level, xMod, yMod); if (this.getFlip()) { - bounds.x = 1 - bounds.x - bounds.width; + bounds.x = Math.max(0, 1 - bounds.x - bounds.width); } bounds.x += (x - xMod) / numTiles.x; bounds.y += (this._worldHeightCurrent / this._worldWidthCurrent) * ((y - yMod) / numTiles.y); @@ -22218,7 +24761,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag imageX = imageX.x; } - var point = this._imageToViewportDelta(imageX, imageY); + var point = this._imageToViewportDelta(imageX, imageY, current); if (current) { point.x += this._xSpring.current.value; point.y += this._ySpring.current.value; @@ -22404,6 +24947,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._xSpring.resetTo(position.x); this._ySpring.resetTo(position.y); this._needsDraw = true; + this._needsUpdate = true; } else { if (sameTarget) { return; @@ -22412,6 +24956,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._xSpring.springTo(position.x); this._ySpring.springTo(position.y); this._needsDraw = true; + this._needsUpdate = true; } if (!sameTarget) { @@ -22450,7 +24995,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * ] */ setCroppingPolygons: function( polygons ) { - var isXYObject = function(obj) { return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number'); }; @@ -22476,10 +25020,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._croppingPolygons = polygons.map(function(polygon){ return objectToSimpleXYObject(polygon); }); + this._needsDraw = true; } catch (e) { $.console.error('[TiledImage.setCroppingPolygons] Cropping polygon format not supported'); $.console.error(e); - this._croppingPolygons = null; + this.resetCroppingPolygons(); } }, @@ -22489,6 +25034,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ resetCroppingPolygons: function() { this._croppingPolygons = null; + this._needsDraw = true; }, /** @@ -22580,6 +25126,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._clip = null; } + this._needsUpdate = true; this._needsDraw = true; /** * Raised when the TiledImage's clip is changed. @@ -22597,7 +25144,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @returns {Boolean} Whether the TiledImage should be flipped before rendering. */ getFlip: function() { - return !!this.flipped; + return this.flipped; }, /** @@ -22605,9 +25152,54 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @fires OpenSeadragon.TiledImage.event:bounds-change */ setFlip: function(flip) { - this.flipped = !!flip; + this.flipped = flip; + }, + + get flipped(){ + return this._flipped; + }, + set flipped(flipped){ + let changed = this._flipped !== !!flipped; + this._flipped = !!flipped; + if(changed){ + this.update(true); + this._needsDraw = true; + this._raiseBoundsChange(); + } + }, + + get wrapHorizontal(){ + return this._wrapHorizontal; + }, + set wrapHorizontal(wrap){ + let changed = this._wrapHorizontal !== !!wrap; + this._wrapHorizontal = !!wrap; + if(this._initialized && changed){ + this.update(true); + this._needsDraw = true; + // this._raiseBoundsChange(); + } + }, + + get wrapVertical(){ + return this._wrapVertical; + }, + set wrapVertical(wrap){ + let changed = this._wrapVertical !== !!wrap; + this._wrapVertical = !!wrap; + if(this._initialized && changed){ + this.update(true); + this._needsDraw = true; + // this._raiseBoundsChange(); + } + }, + + get debugMode(){ + return this._debugMode; + }, + set debugMode(debug){ + this._debugMode = !!debug; this._needsDraw = true; - this._raiseBoundsChange(); }, /** @@ -22622,11 +25214,19 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @fires OpenSeadragon.TiledImage.event:opacity-change */ setOpacity: function(opacity) { + this.opacity = opacity; + }, + + get opacity() { + return this._opacity; + }, + + set opacity(opacity) { if (opacity === this.opacity) { return; } - this.opacity = opacity; + this._opacity = opacity; this._needsDraw = true; /** * Raised when the TiledImage's opacity is changed. @@ -22687,9 +25287,58 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._degreesSpring.springTo(degrees); } this._needsDraw = true; + this._needsUpdate = true; this._raiseBoundsChange(); }, + /** + * Get the region of this tiled image that falls within the viewport. + * @returns {OpenSeadragon.Rect} the region of this tiled image that falls within the viewport. + * Returns false for images with opacity==0 unless preload==true + */ + getDrawArea: function(){ + + if( this._opacity === 0 && !this._preload){ + return false; + } + + var drawArea = this._viewportToTiledImageRectangle( + this.viewport.getBoundsWithMargins(true)); + + if (!this.wrapHorizontal && !this.wrapVertical) { + var tiledImageBounds = this._viewportToTiledImageRectangle( + this.getClippedBounds(true)); + drawArea = drawArea.intersection(tiledImageBounds); + } + + return drawArea; + }, + + /** + * + * @returns {Array} Array of Tiles that make up the current view + */ + getTilesToDraw: function(){ + // start with all the tiles added to this._tilesToDraw during the most recent + // call to this.update. Then update them so the blending and coverage properties + // are updated based on the current time + let tileArray = this._tilesToDraw.flat(); + + // update all tiles, which can change the coverage provided + this._updateTilesInViewport(tileArray); + + // _tilesToDraw might have been updated by the update; refresh it + tileArray = this._tilesToDraw.flat(); + + // mark the tiles as being drawn, so that they won't be discarded from + // the tileCache + tileArray.forEach(tileInfo => { + tileInfo.tile.beingDrawn = true; + }); + this._lastDrawn = tileArray; + return tileArray; + }, + /** * Get the point around which this tiled image is rotated * @private @@ -22700,23 +25349,16 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return this.getBoundsNoRotate(current).getCenter(); }, - /** - * @returns {String} The TiledImage's current compositeOperation. - */ - getCompositeOperation: function() { - return this.compositeOperation; + get compositeOperation(){ + return this._compositeOperation; }, - /** - * @param {String} compositeOperation the tiled image should be drawn with this globalCompositeOperation. - * @fires OpenSeadragon.TiledImage.event:composite-operation-change - */ - setCompositeOperation: function(compositeOperation) { - if (compositeOperation === this.compositeOperation) { + set compositeOperation(compositeOperation){ + + if (compositeOperation === this._compositeOperation) { return; } - - this.compositeOperation = compositeOperation; + this._compositeOperation = compositeOperation; this._needsDraw = true; /** * Raised when the TiledImage's opacity is changed. @@ -22729,8 +25371,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @property {?Object} userData - Arbitrary subscriber-defined object. */ this.raiseEvent('composite-operation-change', { - compositeOperation: this.compositeOperation + compositeOperation: this._compositeOperation }); + + }, + + /** + * @returns {String} The TiledImage's current compositeOperation. + */ + getCompositeOperation: function() { + return this._compositeOperation; + }, + + /** + * @param {String} compositeOperation the tiled image should be drawn with this globalCompositeOperation. + * @fires OpenSeadragon.TiledImage.event:composite-operation-change + */ + setCompositeOperation: function(compositeOperation) { + this.compositeOperation = compositeOperation; //invokes setter }, /** @@ -22828,6 +25486,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._scaleSpring.resetTo(scale); this._updateForScale(); this._needsDraw = true; + this._needsUpdate = true; } else { if (sameTarget) { return; @@ -22836,6 +25495,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._scaleSpring.springTo(scale); this._updateForScale(); this._needsDraw = true; + this._needsUpdate = true; } if (!sameTarget) { @@ -22898,650 +25558,365 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }; }, - /** - * @private - * @inner - * Pretty much every other line in this needs to be documented so it's clear - * how each piece of this routine contributes to the drawing process. That's - * why there are so many TODO's inside this function. - */ - _updateViewport: function() { - this._needsDraw = false; - this._tilesLoading = 0; - this.loadingCoverage = {}; - - // Reset tile's internal drawn state - while (this.lastDrawn.length > 0) { - var tile = this.lastDrawn.pop(); - tile.beingDrawn = false; - } - - var viewport = this.viewport; - var drawArea = this._viewportToTiledImageRectangle( - viewport.getBoundsWithMargins(true)); - - if (!this.wrapHorizontal && !this.wrapVertical) { - var tiledImageBounds = this._viewportToTiledImageRectangle( - this.getClippedBounds(true)); - drawArea = drawArea.intersection(tiledImageBounds); - if (drawArea === null) { - return; - } - } - + // returns boolean flag of whether the image should be marked as fully loaded + _updateLevelsForViewport: function(){ var levelsInterval = this._getLevelsInterval(); - var lowestLevel = levelsInterval.lowestLevel; - var highestLevel = levelsInterval.highestLevel; - var bestTile = null; - var haveDrawn = false; + var lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom + var highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom + var bestTiles = []; + var drawArea = this.getDrawArea(); var currentTime = $.now(); - // Update any level that will be drawn - for (var level = highestLevel; level >= lowestLevel; level--) { - var drawLevel = false; - - //Avoid calculations for draw if we have already drawn this - var currentRenderPixelRatio = viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio(level), - true - ).x * this._scaleSpring.current.value; - - if (level === lowestLevel || - (!haveDrawn && currentRenderPixelRatio >= this.minPixelRatio)) { - drawLevel = true; - haveDrawn = true; - } else if (!haveDrawn) { - continue; - } - - //Perform calculations for draw if we haven't drawn this - var targetRenderPixelRatio = viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio(level), - false - ).x * this._scaleSpring.current.value; - - var targetZeroRatio = viewport.deltaPixelsFromPointsNoRotate( - this.source.getPixelRatio( - Math.max( - this.source.getClosestLevel(), - 0 - ) - ), - false - ).x * this._scaleSpring.current.value; - - var optimalRatio = this.immediateRender ? 1 : targetZeroRatio; - var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5); - var levelVisibility = optimalRatio / Math.abs( - optimalRatio - targetRenderPixelRatio - ); - - // Update the level and keep track of 'best' tile to load - bestTile = this._updateLevel( - haveDrawn, - drawLevel, - level, - levelOpacity, - levelVisibility, - drawArea, - currentTime, - bestTile - ); - - // Stop the loop if lower-res tiles would all be covered by - // already drawn tiles - if (this._providesCoverage(this.coverage, level)) { - break; - } - } - - // Perform the actual drawing - this._drawTiles(this.lastDrawn); - - // Load the new 'best' tile - if (bestTile && !bestTile.context2D) { - this._loadTile(bestTile, currentTime); - this._needsDraw = true; - this._setFullyLoaded(false); - } else { - this._setFullyLoaded(this._tilesLoading === 0); - } - }, - - // private - _getCornerTiles: function(level, topLeftBound, bottomRightBound) { - var leftX; - var rightX; - if (this.wrapHorizontal) { - leftX = $.positiveModulo(topLeftBound.x, 1); - rightX = $.positiveModulo(bottomRightBound.x, 1); - } else { - leftX = Math.max(0, topLeftBound.x); - rightX = Math.min(1, bottomRightBound.x); - } - var topY; - var bottomY; - var aspectRatio = 1 / this.source.aspectRatio; - if (this.wrapVertical) { - topY = $.positiveModulo(topLeftBound.y, aspectRatio); - bottomY = $.positiveModulo(bottomRightBound.y, aspectRatio); - } else { - topY = Math.max(0, topLeftBound.y); - bottomY = Math.min(aspectRatio, bottomRightBound.y); - } - - var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY)); - var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY)); - var numTiles = this.source.getNumTiles(level); - - if (this.wrapHorizontal) { - topLeftTile.x += numTiles.x * Math.floor(topLeftBound.x); - bottomRightTile.x += numTiles.x * Math.floor(bottomRightBound.x); - } - if (this.wrapVertical) { - topLeftTile.y += numTiles.y * Math.floor(topLeftBound.y / aspectRatio); - bottomRightTile.y += numTiles.y * Math.floor(bottomRightBound.y / aspectRatio); - } - - return { - topLeft: topLeftTile, - bottomRight: bottomRightTile, - }; - }, - - /** - * Updates all tiles at a given resolution level. - * @private - * @param {Boolean} haveDrawn - * @param {Boolean} drawLevel - * @param {Number} level - * @param {Number} levelOpacity - * @param {Number} levelVisibility - * @param {OpenSeadragon.Rect} drawArea - * @param {Number} currentTime - * @param {OpenSeadragon.Tile} best - The current "best" tile to draw. - */ - _updateLevel: function(haveDrawn, drawLevel, level, levelOpacity, - levelVisibility, drawArea, currentTime, best) { - - var topLeftBound = drawArea.getBoundingBox().getTopLeft(); - var bottomRightBound = drawArea.getBoundingBox().getBottomRight(); - - if (this.viewer) { - /** - * - Needs documentation - - * - * @event update-level - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {Object} havedrawn - * @property {Object} level - * @property {Object} opacity - * @property {Object} visibility - * @property {OpenSeadragon.Rect} drawArea - * @property {Object} topleft deprecated, use drawArea instead - * @property {Object} bottomright deprecated, use drawArea instead - * @property {Object} currenttime - * @property {Object} best - * @property {?Object} userData - Arbitrary subscriber-defined object. - */ - this.viewer.raiseEvent('update-level', { - tiledImage: this, - havedrawn: haveDrawn, - level: level, - opacity: levelOpacity, - visibility: levelVisibility, - drawArea: drawArea, - topleft: topLeftBound, - bottomright: bottomRightBound, - currenttime: currentTime, - best: best - }); - } - - this._resetCoverage(this.coverage, level); - this._resetCoverage(this.loadingCoverage, level); - - //OK, a new drawing so do your calculations - var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound); - var topLeftTile = cornerTiles.topLeft; - var bottomRightTile = cornerTiles.bottomRight; - var numberOfTiles = this.source.getNumTiles(level); - - var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter()); - - if (this.getFlip()) { - // The right-most tile can be narrower than the others. When flipped, - // this tile is now on the left. Because it is narrower than the normal - // left-most tile, the subsequent tiles may not be wide enough to completely - // fill the viewport. Fix this by rendering an extra column of tiles. If we - // are not wrapping, make sure we never render more than the number of tiles - // in the image. - bottomRightTile.x += 1; - if (!this.wrapHorizontal) { - bottomRightTile.x = Math.min(bottomRightTile.x, numberOfTiles.x - 1); - } - } - - for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) { - for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) { - - var flippedX; - if (this.getFlip()) { - var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x; - flippedX = x + numberOfTiles.x - xMod - xMod - 1; - } else { - flippedX = x; - } - - if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) { - // This tile is outside of the viewport, no need to draw it - continue; - } - - best = this._updateTile( - drawLevel, - haveDrawn, - flippedX, y, - level, - levelOpacity, - levelVisibility, - viewportCenter, - numberOfTiles, - currentTime, - best - ); - } - } - - return best; - }, - - /** - * @private - * @inner - * Update a single tile at a particular resolution level. - * @param {Boolean} haveDrawn - * @param {Boolean} drawLevel - * @param {Number} x - * @param {Number} y - * @param {Number} level - * @param {Number} levelOpacity - * @param {Number} levelVisibility - * @param {OpenSeadragon.Point} viewportCenter - * @param {Number} numberOfTiles - * @param {Number} currentTime - * @param {OpenSeadragon.Tile} best - The current "best" tile to draw. - */ - _updateTile: function( haveDrawn, drawLevel, x, y, level, levelOpacity, - levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ - - var tile = this._getTile( - x, y, - level, - currentTime, - numberOfTiles, - this._worldWidthCurrent, - this._worldHeightCurrent - ), - drawTile = drawLevel; - - if( this.viewer ){ - /** - * - Needs documentation - - * - * @event update-tile - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {OpenSeadragon.Tile} tile - * @property {?Object} userData - Arbitrary subscriber-defined object. - */ - this.viewer.raiseEvent( 'update-tile', { - tiledImage: this, - tile: tile - }); - } - - this._setCoverage( this.coverage, level, x, y, false ); + // reset each tile's beingDrawn flag + this._lastDrawn.forEach(tileinfo => { + tileinfo.tile.beingDrawn = false; + }); + // clear the list of tiles to draw + this._tilesToDraw = []; + this._tilesLoading = 0; + this.loadingCoverage = {}; - var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y); - this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage); + if(!drawArea){ + this._needsDraw = false; + return this._fullyLoaded; + } - if ( !tile.exists ) { - return best; + // make a list of levels to use for the current zoom level + var levelList = new Array(highestLevel - lowestLevel + 1); + // go from highest to lowest resolution + for(let i = 0, level = highestLevel; level >= lowestLevel; level--, i++){ + levelList[i] = level; } - if ( haveDrawn && !drawTile ) { - if ( this._isCovered( this.coverage, level, x, y ) ) { - this._setCoverage( this.coverage, level, x, y, true ); - } else { - drawTile = true; + // if a single-tile level is loaded, add that to the end of the list + // as a fallback to use during zooming out, until a lower-res tile is + // loaded + for(let level = highestLevel + 1; level <= this.source.maxLevel; level++){ + var tile = ( + this.tilesMatrix[level] && + this.tilesMatrix[level][0] && + this.tilesMatrix[level][0][0] + ); + if(tile && tile.isBottomMost && tile.isRightMost && tile.loaded){ + levelList.push(level); + break; } } - if ( !drawTile ) { - return best; - } - this._positionTile( - tile, - this.source.tileOverlap, - this.viewport, - viewportCenter, - levelVisibility - ); + // Update any level that will be drawn. + // We are iterating from highest resolution to lowest resolution + // Once a level fully covers the viewport the loop is halted and + // lower-resolution levels are skipped + let useLevel = false; + for (let i = 0; i < levelList.length; i++) { + let level = levelList[i]; - if (!tile.loaded) { - if (tile.context2D) { - this._setTileLoaded(tile); - } else { - var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); - if (imageRecord) { - this._setTileLoaded(tile, imageRecord.getData()); - } + var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + true + ).x * this._scaleSpring.current.value; + + // make sure we skip levels until currentRenderPixelRatio becomes >= minPixelRatio + // but always use the last level in the list so we draw something + if (i === levelList.length - 1 || currentRenderPixelRatio >= this.minPixelRatio ) { + useLevel = true; + } else if (!useLevel) { + continue; } - } - if ( tile.loaded ) { - var needsDraw = this._blendTile( - tile, - x, y, + var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + false + ).x * this._scaleSpring.current.value; + + var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio( + Math.max( + this.source.getClosestLevel(), + 0 + ) + ), + false + ).x * this._scaleSpring.current.value; + + var optimalRatio = this.immediateRender ? 1 : targetZeroRatio; + var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5); + var levelVisibility = optimalRatio / Math.abs( + optimalRatio - targetRenderPixelRatio + ); + + // Update the level and keep track of 'best' tiles to load + var result = this._updateLevel( level, levelOpacity, - currentTime + levelVisibility, + drawArea, + currentTime, + bestTiles ); - if ( needsDraw ) { - this._needsDraw = true; + bestTiles = result.bestTiles; + var tiles = result.updatedTiles.filter(tile => tile.loaded); + var makeTileInfoObject = (function(level, levelOpacity, currentTime){ + return function(tile){ + return { + tile: tile, + level: level, + levelOpacity: levelOpacity, + currentTime: currentTime + }; + }; + })(level, levelOpacity, currentTime); + + this._tilesToDraw[level] = tiles.map(makeTileInfoObject); + + // Stop the loop if lower-res tiles would all be covered by + // already drawn tiles + if (this._providesCoverage(this.coverage, level)) { + break; } - } else if ( tile.loading ) { - // the tile is already in the download queue - this._tilesLoading++; - } else if (!loadingCoverage) { - best = this._compareTiles( best, tile ); } - return best; + + // Load the new 'best' n tiles + if (bestTiles && bestTiles.length > 0) { + bestTiles.forEach(function (tile) { + if (tile && !tile.context2D) { + this._loadTile(tile, currentTime); + } + }, this); + + this._needsDraw = true; + return false; + } else { + return this._tilesLoading === 0; + } + + // Update + }, /** + * Update all tiles that contribute to the current view * @private - * @inner - * Obtains a tile at the given location. - * @param {Number} x - * @param {Number} y - * @param {Number} level - * @param {Number} time - * @param {Number} numTiles - * @param {Number} worldWidth - * @param {Number} worldHeight - * @returns {OpenSeadragon.Tile} + * */ - _getTile: function( - x, y, - level, - time, - numTiles, - worldWidth, - worldHeight - ) { - var xMod, - yMod, - bounds, - sourceBounds, - exists, - urlOrGetter, - post, - ajaxHeaders, - context2D, - tile, - tilesMatrix = this.tilesMatrix, - tileSource = this.source; + _updateTilesInViewport: function(tiles) { + let currentTime = $.now(); + let _this = this; + this._tilesLoading = 0; + this._wasBlending = this._isBlending; + this._isBlending = false; + this.loadingCoverage = {}; + let lowestLevel = tiles.length ? tiles[0].level : 0; - if ( !tilesMatrix[ level ] ) { - tilesMatrix[ level ] = {}; - } - if ( !tilesMatrix[ level ][ x ] ) { - tilesMatrix[ level ][ x ] = {}; + let drawArea = this.getDrawArea(); + if(!drawArea){ + return; } - if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ x ][ y ].flipped !== !this.flipped ) { - xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x; - yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y; - bounds = this.getTileBounds( level, x, y ); - sourceBounds = tileSource.getTileBounds( level, xMod, yMod, true ); - exists = tileSource.tileExists( level, xMod, yMod ); - urlOrGetter = tileSource.getTileUrl( level, xMod, yMod ); - post = tileSource.getTilePostData( level, xMod, yMod ); - - // Headers are only applicable if loadTilesWithAjax is set - if (this.loadTilesWithAjax) { - ajaxHeaders = tileSource.getTileAjaxHeaders( level, xMod, yMod ); - // Combine tile AJAX headers with tiled image AJAX headers (if applicable) - if ($.isPlainObject(this.ajaxHeaders)) { - ajaxHeaders = $.extend({}, this.ajaxHeaders, ajaxHeaders); - } - } else { - ajaxHeaders = null; + function updateTile(info){ + let tile = info.tile; + if(tile && tile.loaded){ + let tileIsBlending = _this._blendTile( + tile, + tile.x, + tile.y, + info.level, + info.levelOpacity, + currentTime, + lowestLevel + ); + _this._isBlending = _this._isBlending || tileIsBlending; + _this._needsDraw = _this._needsDraw || tileIsBlending || _this._wasBlending; } + } - context2D = tileSource.getContext2D ? - tileSource.getContext2D(level, xMod, yMod) : undefined; - - tile = new $.Tile( - level, - x, - y, - bounds, - exists, - urlOrGetter, - context2D, - this.loadTilesWithAjax, - ajaxHeaders, - sourceBounds, - post, - tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post) - ); - - if (this.getFlip()) { - if (xMod === 0) { - tile.isRightMost = true; - } - } else { - if (xMod === numTiles.x - 1) { - tile.isRightMost = true; - } + // Update each tile in the list of tiles. As the tiles are updated, + // the coverage provided is also updated. If a level provides coverage + // as part of this process, discard tiles from lower levels + let level = 0; + for(let i = 0; i < tiles.length; i++){ + let tile = tiles[i]; + updateTile(tile); + if(this._providesCoverage(this.coverage, tile.level)){ + level = Math.max(level, tile.level); } - - if (yMod === numTiles.y - 1) { - tile.isBottomMost = true; + } + if(level > 0){ + for( let levelKey in this._tilesToDraw ){ + if( levelKey < level ){ + delete this._tilesToDraw[levelKey]; + } } - - tile.flipped = this.flipped; - - tilesMatrix[ level ][ x ][ y ] = tile; } - tile = tilesMatrix[ level ][ x ][ y ]; - tile.lastTouchTime = time; - - return tile; }, /** + * Updates the opacity of a tile according to the time it has been on screen + * to perform a fade-in. + * Updates coverage once a tile is fully opaque. + * Returns whether the fade-in has completed. * @private - * @inner - * Dispatch a job to the ImageLoader to load the Image for a Tile. + * * @param {OpenSeadragon.Tile} tile - * @param {Number} time + * @param {Number} x + * @param {Number} y + * @param {Number} level + * @param {Number} levelOpacity + * @param {Number} currentTime + * @param {Boolean} lowestLevel + * @returns {Boolean} true if blending did not yet finish */ - _loadTile: function(tile, time ) { - var _this = this; - tile.loading = true; - this._imageLoader.addJob({ - src: tile.getUrl(), - tile: tile, - source: this.source, - postData: tile.postData, - loadWithAjax: tile.loadWithAjax, - ajaxHeaders: tile.ajaxHeaders, - crossOriginPolicy: this.crossOriginPolicy, - ajaxWithCredentials: this.ajaxWithCredentials, - callback: function( data, errorMsg, tileRequest ){ - _this._onTileLoad( tile, time, data, errorMsg, tileRequest ); - }, - abort: function() { - tile.loading = false; - } - }); + _blendTile: function(tile, x, y, level, levelOpacity, currentTime, lowestLevel ){ + let blendTimeMillis = 1000 * this.blendTime, + deltaTime, + opacity; + + if ( !tile.blendStart ) { + tile.blendStart = currentTime; + } + + deltaTime = currentTime - tile.blendStart; + opacity = blendTimeMillis ? Math.min( 1, deltaTime / ( blendTimeMillis ) ) : 1; + + // if this tile is at the lowest level being drawn, render at opacity=1 + if(level === lowestLevel){ + opacity = 1; + deltaTime = blendTimeMillis; + } + + if ( this.alwaysBlend ) { + opacity *= levelOpacity; + } + tile.opacity = opacity; + + if ( opacity === 1 ) { + this._setCoverage( this.coverage, level, x, y, true ); + this._hasOpaqueTile = true; + } + // return true if the tile is still blending + return deltaTime < blendTimeMillis; }, /** + * Updates all tiles at a given resolution level. * @private - * @inner - * Callback fired when a Tile's Image finished downloading. - * @param {OpenSeadragon.Tile} tile - * @param {Number} time - * @param {*} data image data - * @param {String} errorMsg - * @param {XMLHttpRequest} tileRequest + * @param {Number} level + * @param {Number} levelOpacity + * @param {Number} levelVisibility + * @param {OpenSeadragon.Rect} drawArea + * @param {Number} currentTime + * @param {OpenSeadragon.Tile[]} best Array of the current best tiles + * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile - the current "best" tiles to draw, updatedTiles: OpenSeadragon.Tile) - the updated tiles}. */ - _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) { - if ( !data ) { - $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg ); + _updateLevel: function(level, levelOpacity, + levelVisibility, drawArea, currentTime, best) { + + var topLeftBound = drawArea.getBoundingBox().getTopLeft(); + var bottomRightBound = drawArea.getBoundingBox().getBottomRight(); + + if (this.viewer) { /** - * Triggered when a tile fails to load. + * - Needs documentation - * - * @event tile-load-failed + * @event update-level * @memberof OpenSeadragon.Viewer * @type {object} - * @property {OpenSeadragon.Tile} tile - The tile that failed to load. - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image the tile belongs to. - * @property {number} time - The time in milliseconds when the tile load began. - * @property {string} message - The error message. - * @property {XMLHttpRequest} tileRequest - The XMLHttpRequest used to load the tile if available. + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {Object} havedrawn - deprecated, always true (kept for backwards compatibility) + * @property {Object} level + * @property {Object} opacity + * @property {Object} visibility + * @property {OpenSeadragon.Rect} drawArea + * @property {Object} topleft deprecated, use drawArea instead + * @property {Object} bottomright deprecated, use drawArea instead + * @property {Object} currenttime + * @property {Object[]} best + * @property {?Object} userData - Arbitrary subscriber-defined object. */ - this.viewer.raiseEvent("tile-load-failed", { - tile: tile, + this.viewer.raiseEvent('update-level', { tiledImage: this, - time: time, - message: errorMsg, - tileRequest: tileRequest + havedrawn: true, // deprecated, kept for backwards compatibility + level: level, + opacity: levelOpacity, + visibility: levelVisibility, + drawArea: drawArea, + topleft: topLeftBound, + bottomright: bottomRightBound, + currenttime: currentTime, + best: best }); - tile.loading = false; - tile.exists = false; - return; - } else { - tile.exists = true; - } - - if ( time < this.lastResetTime ) { - $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.getUrl() ); - tile.loading = false; - return; } - var _this = this, - finish = function() { - var ccc = _this.source; - var cutoff = ccc.getClosestLevel(); - _this._setTileLoaded(tile, data, cutoff, tileRequest); - }; + this._resetCoverage(this.coverage, level); + this._resetCoverage(this.loadingCoverage, level); - // Check if we're mid-update; this can happen on IE8 because image load events for - // cached images happen immediately there - if ( !this._midDraw ) { - finish(); - } else { - // Wait until after the update, in case caching unloads any tiles - window.setTimeout( finish, 1); - } - }, + //OK, a new drawing so do your calculations + var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound); + var topLeftTile = cornerTiles.topLeft; + var bottomRightTile = cornerTiles.bottomRight; + var numberOfTiles = this.source.getNumTiles(level); - /** - * @private - * @inner - * @param {OpenSeadragon.Tile} tile - * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @param {Number|undefined} cutoff - * @param {XMLHttpRequest|undefined} tileRequest - */ - _setTileLoaded: function(tile, data, cutoff, tileRequest) { - var increment = 0, - eventFinished = false, - _this = this; + var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter()); - function getCompletionCallback() { - if (eventFinished) { - $.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " + - "Its return value should be called asynchronously."); + if (this.getFlip()) { + // The right-most tile can be narrower than the others. When flipped, + // this tile is now on the left. Because it is narrower than the normal + // left-most tile, the subsequent tiles may not be wide enough to completely + // fill the viewport. Fix this by rendering an extra column of tiles. If we + // are not wrapping, make sure we never render more than the number of tiles + // in the image. + bottomRightTile.x += 1; + if (!this.wrapHorizontal) { + bottomRightTile.x = Math.min(bottomRightTile.x, numberOfTiles.x - 1); } - increment++; - return completionCallback; } + var numTiles = Math.max(0, (bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y)); + var tiles = new Array(numTiles); + var tileIndex = 0; + for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) { + for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) { - function completionCallback() { - increment--; - if (increment === 0) { - tile.loading = false; - tile.loaded = true; - tile.hasTransparency = _this.source.hasTransparency( - tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData - ); - if (!tile.context2D) { - _this._tileCache.cacheTile({ - data: data, - tile: tile, - cutoff: cutoff, - tiledImage: _this - }); + var flippedX; + if (this.getFlip()) { + var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x; + flippedX = x + numberOfTiles.x - xMod - xMod - 1; + } else { + flippedX = x; } - _this._needsDraw = true; + + if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) { + // This tile is outside of the viewport, no need to draw it + continue; + } + + var result = this._updateTile( + flippedX, y, + level, + levelVisibility, + viewportCenter, + numberOfTiles, + currentTime, + best + ); + best = result.bestTiles; + tiles[tileIndex] = result.tile; + tileIndex += 1; } } - /** - * Triggered when a tile has just been loaded in memory. That means that the - * image has been downloaded and can be modified before being drawn to the canvas. - * - * @event tile-loaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {Image|*} image - The image (data) of the tile. Deprecated. - * @property {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. - * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. - * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). - * @property {function} getCompletionCallback - A function giving a callback to call - * when the asynchronous processing of the image is done. The image will be - * marked as entirely loaded when the callback has been called once for each - * call to getCompletionCallback. - */ - - var fallbackCompletion = getCompletionCallback(); - this.viewer.raiseEvent("tile-loaded", { - tile: tile, - tiledImage: this, - tileRequest: tileRequest, - get image() { - $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead."); - return data; - }, - data: data, - getCompletionCallback: getCompletionCallback - }); - eventFinished = true; - // In case the completion callback is never called, we at least force it once. - fallbackCompletion(); + return { + bestTiles: best, + updatedTiles: tiles + }; }, /** * @private - * @inner * @param {OpenSeadragon.Tile} tile * @param {Boolean} overlap * @param {OpenSeadragon.Viewport} viewport @@ -23561,6 +25936,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag boundsSize.x *= this._scaleSpring.current.value; boundsSize.y *= this._scaleSpring.current.value; + tile.positionedBounds.x = boundsTL.x; + tile.positionedBounds.y = boundsTL.y; + tile.positionedBounds.width = boundsSize.x; + tile.positionedBounds.height = boundsSize.y; + var positionC = viewport.pixelFromPointNoRotate(boundsTL, true), positionT = viewport.pixelFromPointNoRotate(boundsTL, false), sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true), @@ -23568,16 +25948,18 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag tileCenter = positionT.plus( sizeT.divide( 2 ) ), tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter ); - if ( !overlap ) { - sizeC = sizeC.plus( new $.Point( 1, 1 ) ); - } + if(this.viewer.drawer.minimumOverlapRequired(this)){ + if ( !overlap ) { + sizeC = sizeC.plus( new $.Point(1, 1)); + } - if (tile.isRightMost && this.wrapHorizontal) { - sizeC.x += 0.75; // Otherwise Firefox and Safari show seams - } + if (tile.isRightMost && this.wrapHorizontal) { + sizeC.x += 0.75; // Otherwise Firefox and Safari show seams + } - if (tile.isBottomMost && this.wrapVertical) { - sizeC.y += 0.75; // Otherwise Firefox and Safari show seams + if (tile.isBottomMost && this.wrapVertical) { + sizeC.y += 0.75; // Otherwise Firefox and Safari show seams + } } tile.position = positionC; @@ -23587,347 +25969,457 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** + * Update a single tile at a particular resolution level. * @private - * @inner - * Updates the opacity of a tile according to the time it has been on screen - * to perform a fade-in. - * Updates coverage once a tile is fully opaque. - * Returns whether the fade-in has completed. - * - * @param {OpenSeadragon.Tile} tile * @param {Number} x * @param {Number} y * @param {Number} level - * @param {Number} levelOpacity + * @param {Number} levelVisibility + * @param {OpenSeadragon.Point} viewportCenter + * @param {Number} numberOfTiles * @param {Number} currentTime - * @returns {Boolean} + * @param {OpenSeadragon.Tile} best - The current "best" tile to draw. + * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile[] - the current best tiles, tile: OpenSeadragon.Tile the current tile} */ - _blendTile: function( tile, x, y, level, levelOpacity, currentTime ){ - var blendTimeMillis = 1000 * this.blendTime, - deltaTime, - opacity; + _updateTile: function( x, y, level, + levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ - if ( !tile.blendStart ) { - tile.blendStart = currentTime; + var tile = this._getTile( + x, y, + level, + currentTime, + numberOfTiles + ); + + if( this.viewer ){ + /** + * - Needs documentation - + * + * @event update-tile + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'update-tile', { + tiledImage: this, + tile: tile + }); } - deltaTime = currentTime - tile.blendStart; - opacity = blendTimeMillis ? Math.min( 1, deltaTime / ( blendTimeMillis ) ) : 1; + this._setCoverage( this.coverage, level, x, y, false ); - if ( this.alwaysBlend ) { - opacity *= levelOpacity; + var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y); + this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage); + + if ( !tile.exists ) { + return { + bestTiles: best, + tile: tile + }; + } + if (tile.loaded && tile.opacity === 1){ + this._setCoverage( this.coverage, level, x, y, true ); + } + + this._positionTile( + tile, + this.source.tileOverlap, + this.viewport, + viewportCenter, + levelVisibility + ); + + if (!tile.loaded) { + if (tile.context2D) { + this._setTileLoaded(tile); + } else { + var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); + if (imageRecord) { + this._setTileLoaded(tile, imageRecord.getData()); + } + } + } + + if ( tile.loading ) { + // the tile is already in the download queue + this._tilesLoading++; + } else if (!loadingCoverage) { + best = this._compareTiles( best, tile, this.maxTilesPerFrame ); + } + + return { + bestTiles: best, + tile: tile + }; + }, + + // private + _getCornerTiles: function(level, topLeftBound, bottomRightBound) { + var leftX; + var rightX; + if (this.wrapHorizontal) { + leftX = $.positiveModulo(topLeftBound.x, 1); + rightX = $.positiveModulo(bottomRightBound.x, 1); + } else { + leftX = Math.max(0, topLeftBound.x); + rightX = Math.min(1, bottomRightBound.x); + } + var topY; + var bottomY; + var aspectRatio = 1 / this.source.aspectRatio; + if (this.wrapVertical) { + topY = $.positiveModulo(topLeftBound.y, aspectRatio); + bottomY = $.positiveModulo(bottomRightBound.y, aspectRatio); + } else { + topY = Math.max(0, topLeftBound.y); + bottomY = Math.min(aspectRatio, bottomRightBound.y); } - tile.opacity = opacity; - - this.lastDrawn.push( tile ); + var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY)); + var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY)); + var numTiles = this.source.getNumTiles(level); - if ( opacity === 1 ) { - this._setCoverage( this.coverage, level, x, y, true ); - this._hasOpaqueTile = true; - } else if ( deltaTime < blendTimeMillis ) { - return true; + if (this.wrapHorizontal) { + topLeftTile.x += numTiles.x * Math.floor(topLeftBound.x); + bottomRightTile.x += numTiles.x * Math.floor(bottomRightBound.x); } - - return false; - }, - - - /** - * @private - * @inner - * Determines whether the 'last best' tile for the area is better than the - * tile in question. - * - * @param {OpenSeadragon.Tile} previousBest - * @param {OpenSeadragon.Tile} tile - * @returns {OpenSeadragon.Tile} The new best tile. - */ - _compareTiles: function( previousBest, tile ) { - if ( !previousBest ) { - return tile; + if (this.wrapVertical) { + topLeftTile.y += numTiles.y * Math.floor(topLeftBound.y / aspectRatio); + bottomRightTile.y += numTiles.y * Math.floor(bottomRightBound.y / aspectRatio); } - if ( tile.visibility > previousBest.visibility ) { - return tile; - } else if ( tile.visibility === previousBest.visibility ) { - if ( tile.squaredDistance < previousBest.squaredDistance ) { - return tile; - } - } - return previousBest; + return { + topLeft: topLeftTile, + bottomRight: bottomRightTile, + }; }, /** + * Obtains a tile at the given location. * @private - * @inner - * Draws a TiledImage. - * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. + * @param {Number} x + * @param {Number} y + * @param {Number} level + * @param {Number} time + * @param {Number} numTiles + * @returns {OpenSeadragon.Tile} */ - _drawTiles: function( lastDrawn ) { - if (this.opacity === 0 || (lastDrawn.length === 0 && !this.placeholderFillStyle)) { - return; - } - - var tile = lastDrawn[0]; - var useSketch; + _getTile: function( + x, y, + level, + time, + numTiles + ) { + var xMod, + yMod, + bounds, + sourceBounds, + exists, + urlOrGetter, + post, + ajaxHeaders, + context2D, + tile, + tilesMatrix = this.tilesMatrix, + tileSource = this.source; - if (tile) { - useSketch = this.opacity < 1 || - (this.compositeOperation && this.compositeOperation !== 'source-over') || - (!this._isBottomItem() && - this.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); + if ( !tilesMatrix[ level ] ) { + tilesMatrix[ level ] = {}; } - - var sketchScale; - var sketchTranslate; - - var zoom = this.viewport.getZoom(true); - var imageZoom = this.viewportToImageZoom(zoom); - - if (lastDrawn.length > 1 && - imageZoom > this.smoothTileEdgesMinZoom && - !this.iOSDevice && - this.getRotation(true) % 360 === 0 && // TODO: support tile edge smoothing with tiled image rotation. - $.supportsCanvas && this.viewer.useCanvas) { - // When zoomed in a lot (>100%) the tile edges are visible. - // So we have to composite them at ~100% and scale them up together. - // Note: Disabled on iOS devices per default as it causes a native crash - useSketch = true; - sketchScale = tile.getScaleForEdgeSmoothing(); - sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale, - this._drawer.getCanvasSize(false), - this._drawer.getCanvasSize(true)); + if ( !tilesMatrix[ level ][ x ] ) { + tilesMatrix[ level ][ x ] = {}; } - var bounds; - if (useSketch) { - if (!sketchScale) { - // Except when edge smoothing, we only clean the part of the - // sketch canvas we are going to use for performance reasons. - bounds = this.viewport.viewportToViewerElementRectangle( - this.getClippedBounds(true)) - .getIntegerBoundingBox(); + if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ x ][ y ].flipped !== !this.flipped ) { + xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x; + yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y; + bounds = this.getTileBounds( level, x, y ); + sourceBounds = tileSource.getTileBounds( level, xMod, yMod, true ); + exists = tileSource.tileExists( level, xMod, yMod ); + urlOrGetter = tileSource.getTileUrl( level, xMod, yMod ); + post = tileSource.getTilePostData( level, xMod, yMod ); - if(this._drawer.viewer.viewport.getFlip()) { - if (this.viewport.getRotation(true) % 360 !== 0 || - this.getRotation(true) % 360 !== 0) { - bounds.x = this._drawer.viewer.container.clientWidth - (bounds.x + bounds.width); - } + // Headers are only applicable if loadTilesWithAjax is set + if (this.loadTilesWithAjax) { + ajaxHeaders = tileSource.getTileAjaxHeaders( level, xMod, yMod ); + // Combine tile AJAX headers with tiled image AJAX headers (if applicable) + if ($.isPlainObject(this.ajaxHeaders)) { + ajaxHeaders = $.extend({}, this.ajaxHeaders, ajaxHeaders); } - - bounds = bounds.times($.pixelDensityRatio); + } else { + ajaxHeaders = null; } - this._drawer._clear(true, bounds); - } - // When scaling, we must rotate only when blending the sketch canvas to - // avoid interpolation - if (!sketchScale) { - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.viewport.getRotation(true), - useSketch: useSketch - }); - } - if (this.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.getRotation(true), - point: this.viewport.pixelFromPointNoRotate( - this._getRotationPoint(true), true), - useSketch: useSketch - }); - } + context2D = tileSource.getContext2D ? + tileSource.getContext2D(level, xMod, yMod) : undefined; + + tile = new $.Tile( + level, + x, + y, + bounds, + exists, + urlOrGetter, + context2D, + this.loadTilesWithAjax, + ajaxHeaders, + sourceBounds, + post, + tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post) + ); - if (this.viewport.getRotation(true) % 360 === 0 && - this.getRotation(true) % 360 === 0) { - if(this._drawer.viewer.viewport.getFlip()) { - this._drawer._flip(); + if (this.getFlip()) { + if (xMod === 0) { + tile.isRightMost = true; + } + } else { + if (xMod === numTiles.x - 1) { + tile.isRightMost = true; } } - } - - var usedClip = false; - if ( this._clip ) { - this._drawer.saveContext(useSketch); - var box = this.imageToViewportRectangle(this._clip, true); - box = box.rotate(-this.getRotation(true), this._getRotationPoint(true)); - var clipRect = this._drawer.viewportToDrawerRectangle(box); - if (sketchScale) { - clipRect = clipRect.times(sketchScale); - } - if (sketchTranslate) { - clipRect = clipRect.translate(sketchTranslate); + if (yMod === numTiles.y - 1) { + tile.isBottomMost = true; } - this._drawer.setClip(clipRect, useSketch); - usedClip = true; - } + tile.flipped = this.flipped; - if (this._croppingPolygons) { - var self = this; - this._drawer.saveContext(useSketch); - try { - var polygons = this._croppingPolygons.map(function (polygon) { - return polygon.map(function (coord) { - var point = self - .imageToViewportCoordinates(coord.x, coord.y, true) - .rotate(-self.getRotation(true), self._getRotationPoint(true)); - var clipPoint = self._drawer.viewportCoordToDrawerCoord(point); - if (sketchScale) { - clipPoint = clipPoint.times(sketchScale); - } - if (sketchTranslate) { - clipPoint = clipPoint.plus(sketchTranslate); - } - return clipPoint; - }); - }); - this._drawer.clipWithPolygons(polygons, useSketch); - } catch (e) { - $.console.error(e); - } - usedClip = true; + tilesMatrix[ level ][ x ][ y ] = tile; } - if ( this.placeholderFillStyle && this._hasOpaqueTile === false ) { - var placeholderRect = this._drawer.viewportToDrawerRectangle(this.getBounds(true)); - if (sketchScale) { - placeholderRect = placeholderRect.times(sketchScale); - } - if (sketchTranslate) { - placeholderRect = placeholderRect.translate(sketchTranslate); - } + tile = tilesMatrix[ level ][ x ][ y ]; + tile.lastTouchTime = time; - var fillStyle = null; - if ( typeof this.placeholderFillStyle === "function" ) { - fillStyle = this.placeholderFillStyle(this, this._drawer.context); - } - else { - fillStyle = this.placeholderFillStyle; + return tile; + }, + + /** + * Dispatch a job to the ImageLoader to load the Image for a Tile. + * @private + * @param {OpenSeadragon.Tile} tile + * @param {Number} time + */ + _loadTile: function(tile, time ) { + var _this = this; + tile.loading = true; + this._imageLoader.addJob({ + src: tile.getUrl(), + tile: tile, + source: this.source, + postData: tile.postData, + loadWithAjax: tile.loadWithAjax, + ajaxHeaders: tile.ajaxHeaders, + crossOriginPolicy: this.crossOriginPolicy, + ajaxWithCredentials: this.ajaxWithCredentials, + callback: function( data, errorMsg, tileRequest ){ + _this._onTileLoad( tile, time, data, errorMsg, tileRequest ); + }, + abort: function() { + tile.loading = false; } + }); + }, + + /** + * Callback fired when a Tile's Image finished downloading. + * @private + * @param {OpenSeadragon.Tile} tile + * @param {Number} time + * @param {*} data image data + * @param {String} errorMsg + * @param {XMLHttpRequest} tileRequest + */ + _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) { + if ( !data ) { + $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg ); + /** + * Triggered when a tile fails to load. + * + * @event tile-load-failed + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Tile} tile - The tile that failed to load. + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image the tile belongs to. + * @property {number} time - The time in milliseconds when the tile load began. + * @property {string} message - The error message. + * @property {XMLHttpRequest} tileRequest - The XMLHttpRequest used to load the tile if available. + */ + this.viewer.raiseEvent("tile-load-failed", { + tile: tile, + tiledImage: this, + time: time, + message: errorMsg, + tileRequest: tileRequest + }); + tile.loading = false; + tile.exists = false; + return; + } else { + tile.exists = true; + } - this._drawer.drawRectangle(placeholderRect, fillStyle, useSketch); + if ( time < this.lastResetTime ) { + $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.getUrl() ); + tile.loading = false; + return; } - var subPixelRoundingRule = determineSubPixelRoundingRule(this.subPixelRoundingForTransparency); + var _this = this, + finish = function() { + var ccc = _this.source; + var cutoff = ccc.getClosestLevel(); + _this._setTileLoaded(tile, data, cutoff, tileRequest); + }; - var shouldRoundPositionAndSize = false; - if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) { - shouldRoundPositionAndSize = true; - } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) { - var isAnimating = this.viewer && this.viewer.isAnimating(); - shouldRoundPositionAndSize = !isAnimating; - } + finish(); + }, - for (var i = lastDrawn.length - 1; i >= 0; i--) { - tile = lastDrawn[ i ]; - this._drawer.drawTile( tile, this._drawingHandler, useSketch, sketchScale, - sketchTranslate, shouldRoundPositionAndSize, this.source ); - tile.beingDrawn = true; + /** + * @private + * @param {OpenSeadragon.Tile} tile + * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object + * @param {Number|undefined} cutoff + * @param {XMLHttpRequest|undefined} tileRequest + */ + _setTileLoaded: function(tile, data, cutoff, tileRequest) { + var increment = 0, + eventFinished = false, + _this = this; - if( this.viewer ){ + function getCompletionCallback() { + if (eventFinished) { + $.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " + + "Its return value should be called asynchronously."); + } + increment++; + return completionCallback; + } + + function completionCallback() { + increment--; + if (increment === 0) { + tile.loading = false; + tile.loaded = true; + tile.hasTransparency = _this.source.hasTransparency( + tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData + ); + if (!tile.context2D) { + _this._tileCache.cacheTile({ + data: data, + tile: tile, + cutoff: cutoff, + tiledImage: _this + }); + } /** - * - Needs documentation - + * Triggered when a tile is loaded and pre-processing is compelete, + * and the tile is ready to draw. * - * @event tile-drawn + * @event tile-ready * @memberof OpenSeadragon.Viewer * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {OpenSeadragon.Tile} tile - * @property {?Object} userData - Arbitrary subscriber-defined object. + * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. + * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). + * @private */ - this.viewer.raiseEvent( 'tile-drawn', { - tiledImage: this, - tile: tile + _this.viewer.raiseEvent("tile-ready", { + tile: tile, + tiledImage: _this, + tileRequest: tileRequest }); + _this._needsDraw = true; } } - if ( usedClip ) { - this._drawer.restoreContext( useSketch ); - } + /** + * Triggered when a tile has just been loaded in memory. That means that the + * image has been downloaded and can be modified before being drawn to the canvas. + * + * @event tile-loaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {Image|*} image - The image (data) of the tile. Deprecated. + * @property {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. + * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. + * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). + * @property {function} getCompletionCallback - A function giving a callback to call + * when the asynchronous processing of the image is done. The image will be + * marked as entirely loaded when the callback has been called once for each + * call to getCompletionCallback. + */ - if (!sketchScale) { - if (this.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(useSketch); - } - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(useSketch); - } - } + var fallbackCompletion = getCompletionCallback(); + this.viewer.raiseEvent("tile-loaded", { + tile: tile, + tiledImage: this, + tileRequest: tileRequest, + get image() { + $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead."); + return data; + }, + data: data, + getCompletionCallback: getCompletionCallback + }); + eventFinished = true; + // In case the completion callback is never called, we at least force it once. + fallbackCompletion(); + }, - if (useSketch) { - if (sketchScale) { - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.viewport.getRotation(true), - useSketch: false - }); - } - if (this.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.getRotation(true), - point: this.viewport.pixelFromPointNoRotate( - this._getRotationPoint(true), true), - useSketch: false - }); - } - } - this._drawer.blendSketch({ - opacity: this.opacity, - scale: sketchScale, - translate: sketchTranslate, - compositeOperation: this.compositeOperation, - bounds: bounds - }); - if (sketchScale) { - if (this.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(false); - } - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(false); - } - } - } - if (!sketchScale) { - if (this.viewport.getRotation(true) % 360 === 0 && - this.getRotation(true) % 360 === 0) { - if(this._drawer.viewer.viewport.getFlip()) { - this._drawer._flip(); - } - } + /** + * Determines the 'best tiles' from the given 'last best' tiles and the + * tile in question. + * @private + * + * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far. + * @param {OpenSeadragon.Tile} tile The new tile to consider. + * @param {Number} maxNTiles The max number of best tiles. + * @returns {OpenSeadragon.Tile[]} The new best tiles. + */ + _compareTiles: function( previousBest, tile, maxNTiles ) { + if ( !previousBest ) { + return [tile]; } - - this._drawDebugInfo( lastDrawn ); + previousBest.push(tile); + this._sortTiles(previousBest); + if (previousBest.length > maxNTiles) { + previousBest.pop(); + } + return previousBest; }, /** + * Sorts tiles in an array according to distance and visibility. * @private - * @inner - * Draws special debug information for a TiledImage if in debug mode. - * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. + * + * @param {OpenSeadragon.Tile[]} tiles The tiles. */ - _drawDebugInfo: function( lastDrawn ) { - if( this.debugMode ) { - for ( var i = lastDrawn.length - 1; i >= 0; i-- ) { - var tile = lastDrawn[ i ]; - try { - this._drawer.drawDebugInfo(tile, lastDrawn.length, i, this); - } catch(e) { - $.console.error(e); - } + _sortTiles: function( tiles ) { + tiles.sort(function (a, b) { + if (a === null) { + return 1; } - } + if (b === null) { + return -1; + } + if (a.visibility === b.visibility) { + // sort by smallest squared distance + return (a.squaredDistance - b.squaredDistance); + } else { + // sort by largest visibility value + return (b.visibility - a.visibility); + } + }); }, + /** - * @private - * @inner * Returns true if the given tile provides coverage to lower-level tiles of * lower resolution representing the same content. If neither x nor y is * given, returns true if the entire visible level provides coverage. @@ -23935,6 +26427,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * Note that out-of-bounds tiles provide coverage in this sense, since * there's no content that they would need to cover. Tiles at non-existent * levels that are within the image bounds, however, do not. + * @private * * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean. * @param {Number} level - The resolution level of the tile. @@ -23975,11 +26468,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Returns true if the given tile is completely covered by higher-level * tiles of higher resolution representing the same content. If neither x * nor y is given, returns true if the entire visible level is covered. + * @private * * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean. * @param {Number} level - The resolution level of the tile. @@ -24001,9 +26493,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Sets whether the given tile provides coverage or not. + * @private * * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean. * @param {Number} level - The resolution level of the tile. @@ -24028,11 +26519,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Resets coverage information for the given level. This should be called * after every draw routine. Note that at the beginning of the next draw * routine, coverage for every visible tile should be explicitly set. + * @private * * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean. * @param {Number} level - The resolution level of tiles to completely reset. @@ -24043,72 +26533,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }); -/** - * @private - * @inner - * Defines the value for subpixel rounding to fallback to in case of missing or - * invalid value. - */ -var DEFAULT_SUBPIXEL_ROUNDING_RULE = $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; - -/** - * @private - * @inner - * Checks whether the input value is an invalid subpixel rounding enum value. - * - * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to check. - * @returns {Boolean} Returns true if the input value is none of the expected - * {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} value. - */ -function isSubPixelRoundingRuleUnknown(value) { - return value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS && - value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST && - value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; -} - -/** - * @private - * @inner - * Ensures the returned value is always a valid subpixel rounding enum value, - * defaulting to {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} if input is missing or invalid. - * - * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to normalize. - * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns a valid subpixel rounding enum value. - */ -function normalizeSubPixelRoundingRule(value) { - if (isSubPixelRoundingRuleUnknown(value)) { - return DEFAULT_SUBPIXEL_ROUNDING_RULE; - } - return value; -} - -/** - * @private - * @inner - * Ensures the returned value is always a valid subpixel rounding enum value, - * defaulting to 'NEVER' if input is missing or invalid. - * - * @param {Object} subPixelRoundingRules - A subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}. - * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns the determined subpixel rounding enum value for the - * current browser. - */ -function determineSubPixelRoundingRule(subPixelRoundingRules) { - if (typeof subPixelRoundingRules === 'number') { - return normalizeSubPixelRoundingRule(subPixelRoundingRules); - } - - if (!subPixelRoundingRules || !$.Browser) { - return DEFAULT_SUBPIXEL_ROUNDING_RULE; - } - - var subPixelRoundingRule = subPixelRoundingRules[$.Browser.vendor]; - - if (isSubPixelRoundingRuleUnknown(subPixelRoundingRule)) { - subPixelRoundingRule = subPixelRoundingRules['*']; - } - - return normalizeSubPixelRoundingRule(subPixelRoundingRule); -} }( OpenSeadragon )); @@ -24116,7 +26540,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { * OpenSeadragon - TileCache * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -24350,19 +26774,52 @@ $.TileCache.prototype = { var tile = tileRecord.tile; var tiledImage = tileRecord.tiledImage; + // tile.getCanvasContext should always exist in normal usage (with $.Tile) + // but the tile cache test passes in a dummy object + let context2D = tile.getCanvasContext && tile.getCanvasContext(); + tile.unload(); tile.cacheImageRecord = null; var imageRecord = this._imagesLoaded[tile.cacheKey]; + if(!imageRecord){ + return; + } imageRecord.removeTile(tile); if (!imageRecord.getTileCount()) { + imageRecord.destroy(); delete this._imagesLoaded[tile.cacheKey]; this._imagesLoadedCount--; + + if(context2D){ + /** + * Free up canvas memory + * (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory, + * and Safari keeps canvas until its height and width will be set to 0). + */ + context2D.canvas.width = 0; + context2D.canvas.height = 0; + + /** + * Triggered when an image has just been unloaded + * + * @event image-unloaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded + * @private + */ + tiledImage.viewer.raiseEvent("image-unloaded", { + context2D: context2D, + tile: tile + }); + } + } /** - * Triggered when a tile has just been unloaded from memory. + * Triggered when a tile has just been unloaded from the cache. * * @event tile-unloaded * @memberof OpenSeadragon.Viewer @@ -24374,6 +26831,7 @@ $.TileCache.prototype = { tile: tile, tiledImage: tiledImage }); + } }; @@ -24383,7 +26841,7 @@ $.TileCache.prototype = { * OpenSeadragon - World * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2022 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -24623,11 +27081,14 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W /** * Updates (i.e. animates bounds of) all items. + * @function + * @param viewportChanged Whether the viewport changed, which indicates that + * all TiledImages need to be updated. */ - update: function() { + update: function(viewportChanged) { var animated = false; for ( var i = 0; i < this._items.length; i++ ) { - animated = this._items[i].update() || animated; + animated = this._items[i].update(viewportChanged) || animated; } return animated; @@ -24637,11 +27098,11 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W * Draws all items. */ draw: function() { - for ( var i = 0; i < this._items.length; i++ ) { - this._items[i].draw(); - } - + this.viewer.drawer.draw(this._items); this._needsDraw = false; + this._items.forEach((item) => { + this._needsDraw = item.setDrawn() || this._needsDraw; + }); }, /** diff --git a/openslide/__init__.py b/openslide/__init__.py index 8834578d..09bc03d0 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -14,8 +14,7 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # """A library for reading whole-slide images. @@ -23,20 +22,29 @@ This package provides Python bindings for the OpenSlide library. """ -from collections.abc import Mapping +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from collections.abc import Iterator, Mapping from io import BytesIO +from types import TracebackType +from typing import Literal, TypeVar from PIL import Image, ImageCms from openslide import lowlevel -# For the benefit of library users -from openslide._version import __version__ # noqa: F401 module-imported-but-unused +# Re-exports for the benefit of library users +from openslide._version import ( # noqa: F401 module-imported-but-unused + __version__ as __version__, +) +from openslide.lowlevel import ( + OpenSlideUnsupportedFormatError as OpenSlideUnsupportedFormatError, +) from openslide.lowlevel import ( # noqa: F401 module-imported-but-unused - OpenSlideError, - OpenSlideUnsupportedFormatError, - OpenSlideVersionError, + OpenSlideVersionError as OpenSlideVersionError, ) +from openslide.lowlevel import OpenSlideError as OpenSlideError __library_version__ = lowlevel.get_version() @@ -52,81 +60,99 @@ PROPERTY_NAME_BOUNDS_WIDTH = 'openslide.bounds-width' PROPERTY_NAME_BOUNDS_HEIGHT = 'openslide.bounds-height' +_T = TypeVar('_T') + -class AbstractSlide: +class AbstractSlide(metaclass=ABCMeta): """The base class of a slide object.""" - def __init__(self): - self._profile = None + def __init__(self) -> None: + self._profile: bytes | None = None - def __enter__(self): + def __enter__(self: _T) -> _T: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: self.close() return False @classmethod - def detect_format(cls, filename): + @abstractmethod + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" raise NotImplementedError - def close(self): + @abstractmethod + def close(self) -> None: """Close the slide.""" raise NotImplementedError @property - def level_count(self): + @abstractmethod + def level_count(self) -> int: """The number of levels in the image.""" raise NotImplementedError @property - def level_dimensions(self): - """A list of (width, height) tuples, one for each level of the image. + @abstractmethod + def level_dimensions(self) -> tuple[tuple[int, int], ...]: + """A tuple of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" raise NotImplementedError @property - def dimensions(self): + def dimensions(self) -> tuple[int, int]: """A (width, height) tuple for level 0 of the image.""" return self.level_dimensions[0] @property - def level_downsamples(self): - """A list of downsampling factors for each level of the image. + @abstractmethod + def level_downsamples(self) -> tuple[float, ...]: + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" raise NotImplementedError @property - def properties(self): + @abstractmethod + def properties(self) -> Mapping[str, str]: """Metadata about the image. This is a map: property name -> property value.""" raise NotImplementedError @property - def associated_images(self): + @abstractmethod + def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image.""" raise NotImplementedError @property - def color_profile(self): + def color_profile(self) -> ImageCms.ImageCmsProfile | None: """Color profile for the whole-slide image, or None if unavailable.""" if self._profile is None: return None return ImageCms.getOpenProfile(BytesIO(self._profile)) - def get_best_level_for_downsample(self, downsample): + @abstractmethod + def get_best_level_for_downsample(self, downsample: float) -> int: """Return the best level for displaying the given downsample.""" raise NotImplementedError - def read_region(self, location, level, size): + @abstractmethod + def read_region( + self, location: tuple[int, int], level: int, size: tuple[int, int] + ) -> Image.Image: """Return a PIL.Image containing the contents of the region. location: (x, y) tuple giving the top left pixel in the level 0 @@ -135,17 +161,19 @@ def read_region(self, location, level, size): size: (width, height) tuple giving the region size.""" raise NotImplementedError - def set_cache(self, cache): + def set_cache(self, cache: OpenSlideCache) -> None: # noqa: B027 """Use the specified cache to store recently decoded slide tiles. + This class does not support caching, so this method does nothing. + cache: an OpenSlideCache object.""" - raise NotImplementedError + pass - def get_thumbnail(self, size): + def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: """Return a PIL.Image containing an RGB thumbnail of the image. size: the maximum size of the thumbnail.""" - downsample = max(*(dim / thumb for dim, thumb in zip(self.dimensions, size))) + downsample = max(dim / thumb for dim, thumb in zip(self.dimensions, size)) level = self.get_best_level_for_downsample(downsample) tile = self.read_region((0, 0), level, self.level_dimensions[level]) # Apply on solid background @@ -172,36 +200,36 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename): + def __init__(self, filename: lowlevel.Filename): """Open a whole-slide image.""" AbstractSlide.__init__(self) self._filename = filename - self._osr = lowlevel.open(str(filename)) + self._osr = lowlevel.open(filename) if lowlevel.read_icc_profile.available: self._profile = lowlevel.read_icc_profile(self._osr) - def __repr__(self): + def __repr__(self) -> str: return f'{self.__class__.__name__}({self._filename!r})' @classmethod - def detect_format(cls, filename): + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format vendor of the specified file. If the file format is not recognized, return None.""" - return lowlevel.detect_vendor(str(filename)) + return lowlevel.detect_vendor(filename) - def close(self): + def close(self) -> None: """Close the OpenSlide object.""" lowlevel.close(self._osr) @property - def level_count(self): + def level_count(self) -> int: """The number of levels in the image.""" return lowlevel.get_level_count(self._osr) @property - def level_dimensions(self): - """A list of (width, height) tuples, one for each level of the image. + def level_dimensions(self) -> tuple[tuple[int, int], ...]: + """A tuple of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" return tuple( @@ -209,8 +237,8 @@ def level_dimensions(self): ) @property - def level_downsamples(self): - """A list of downsampling factors for each level of the image. + def level_downsamples(self) -> tuple[float, ...]: + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" return tuple( @@ -218,14 +246,14 @@ def level_downsamples(self): ) @property - def properties(self): + def properties(self) -> Mapping[str, str]: """Metadata about the image. This is a map: property name -> property value.""" return _PropertyMap(self._osr) @property - def associated_images(self): + def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image. @@ -234,11 +262,13 @@ def associated_images(self): are not premultiplied.""" return _AssociatedImageMap(self._osr, self._profile) - def get_best_level_for_downsample(self, downsample): + def get_best_level_for_downsample(self, downsample: float) -> int: """Return the best level for displaying the given downsample.""" return lowlevel.get_best_level_for_downsample(self._osr, downsample) - def read_region(self, location, level, size): + def read_region( + self, location: tuple[int, int], level: int, size: tuple[int, int] + ) -> Image.Image: """Return a PIL.Image containing the contents of the region. location: (x, y) tuple giving the top left pixel in the level 0 @@ -255,7 +285,7 @@ def read_region(self, location, level, size): region.info['icc_profile'] = self._profile return region - def set_cache(self, cache): + def set_cache(self, cache: OpenSlideCache) -> None: """Use the specified cache to store recently decoded slide tiles. By default, the object has a private cache with a default size. @@ -263,49 +293,50 @@ def set_cache(self, cache): cache: an OpenSlideCache object.""" try: llcache = cache._openslide_cache - except AttributeError: - raise TypeError('Not a cache object') + except AttributeError as exc: + raise TypeError('Not a cache object') from exc lowlevel.set_cache(self._osr, llcache) -class _OpenSlideMap(Mapping): - def __init__(self, osr): +class _OpenSlideMap(Mapping[str, _T]): + def __init__(self, osr: lowlevel._OpenSlide): self._osr = osr - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {dict(self)!r}>' - def __len__(self): + def __len__(self) -> int: return len(self._keys()) - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._keys()) - def _keys(self): + @abstractmethod + def _keys(self) -> list[str]: # Private method; always returns list. raise NotImplementedError() -class _PropertyMap(_OpenSlideMap): - def _keys(self): +class _PropertyMap(_OpenSlideMap[str]): + def _keys(self) -> list[str]: return lowlevel.get_property_names(self._osr) - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: v = lowlevel.get_property_value(self._osr, key) if v is None: raise KeyError() return v -class _AssociatedImageMap(_OpenSlideMap): - def __init__(self, osr, profile): +class _AssociatedImageMap(_OpenSlideMap[Image.Image]): + def __init__(self, osr: lowlevel._OpenSlide, profile: bytes | None): _OpenSlideMap.__init__(self, osr) self._profile = profile - def _keys(self): + def _keys(self) -> list[str]: return lowlevel.get_associated_image_names(self._osr) - def __getitem__(self, key): + def __getitem__(self, key: str) -> Image.Image: if key not in self._keys(): raise KeyError() image = lowlevel.read_associated_image(self._osr, key) @@ -327,19 +358,19 @@ class OpenSlideCache: each OpenSlide object has its own cache with a default size. """ - def __init__(self, capacity): + def __init__(self, capacity: int): """Create a tile cache with the specified capacity in bytes.""" self._capacity = capacity self._openslide_cache = lowlevel.cache_create(capacity) - def __repr__(self): + def __repr__(self) -> str: return f'{self.__class__.__name__}({self._capacity!r})' class ImageSlide(AbstractSlide): """A wrapper for a PIL.Image that provides the OpenSlide interface.""" - def __init__(self, file): + def __init__(self, file: lowlevel.Filename | Image.Image): """Open an image file. file can be a filename or a PIL.Image.""" @@ -347,77 +378,86 @@ def __init__(self, file): self._file_arg = file if isinstance(file, Image.Image): self._close = False - self._image = file + self._image: Image.Image | None = file else: self._close = True self._image = Image.open(file) self._profile = self._image.info.get('icc_profile') - def __repr__(self): + def __repr__(self) -> str: return f'{self.__class__.__name__}({self._file_arg!r})' @classmethod - def detect_format(cls, filename): + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" try: with Image.open(filename) as img: - return img.format + # img currently resolves as Any + # https://github.com/python-pillow/Pillow/pull/8362 + return img.format # type: ignore[no-any-return] except OSError: return None - def close(self): + def close(self) -> None: """Close the slide object.""" if self._close: + assert self._image is not None self._image.close() self._close = False self._image = None @property - def level_count(self): + def level_count(self) -> Literal[1]: """The number of levels in the image.""" return 1 @property - def level_dimensions(self): - """A list of (width, height) tuples, one for each level of the image. + def level_dimensions(self) -> tuple[tuple[int, int]]: + """A tuple of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" + if self._image is None: + raise ValueError('Cannot read from a closed slide') return (self._image.size,) @property - def level_downsamples(self): - """A list of downsampling factors for each level of the image. + def level_downsamples(self) -> tuple[float]: + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" return (1.0,) @property - def properties(self): + def properties(self) -> Mapping[str, str]: """Metadata about the image. This is a map: property name -> property value.""" return {} @property - def associated_images(self): + def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image.""" return {} - def get_best_level_for_downsample(self, _downsample): + def get_best_level_for_downsample(self, _downsample: float) -> Literal[0]: """Return the best level for displaying the given downsample.""" return 0 - def read_region(self, location, level, size): + def read_region( + self, location: tuple[int, int], level: int, size: tuple[int, int] + ) -> Image.Image: """Return a PIL.Image containing the contents of the region. location: (x, y) tuple giving the top left pixel in the level 0 reference frame. level: the level number. size: (width, height) tuple giving the region size.""" + if self._image is None: + raise ValueError('Cannot read from a closed slide') if level != 0: raise OpenSlideError("Invalid level") if ['fail' for s in size if s < 0]: @@ -438,23 +478,17 @@ def read_region(self, location, level, size): ]: # "< 0" not a typo # Crop size is greater than zero in both dimensions. # PIL thinks the bottom right is the first *excluded* pixel - crop = self._image.crop(image_topleft + [d + 1 for d in image_bottomright]) + crop_box = tuple(image_topleft + [d + 1 for d in image_bottomright]) tile_offset = tuple(il - l for il, l in zip(image_topleft, location)) + assert len(crop_box) == 4 and len(tile_offset) == 2 + crop = self._image.crop(crop_box) tile.paste(crop, tile_offset) if self._profile is not None: tile.info['icc_profile'] = self._profile return tile - def set_cache(self, cache): - """Use the specified cache to store recently decoded slide tiles. - - ImageSlide does not support caching, so this method does nothing. - - cache: an OpenSlideCache object.""" - pass - -def open_slide(filename): +def open_slide(filename: lowlevel.Filename) -> OpenSlide | ImageSlide: """Open a whole-slide or regular image. Return an OpenSlide object for whole-slide images and an ImageSlide diff --git a/openslide/_convert.c b/openslide/_convert.c index 8c367315..b9422f07 100644 --- a/openslide/_convert.c +++ b/openslide/_convert.c @@ -13,8 +13,7 @@ * License for more details. * * You should have received a copy of the GNU Lesser General Public License - * along with this library; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + * along with this library. If not, see . */ #include @@ -89,7 +88,7 @@ _convert_argb2rgba(PyObject *self, PyObject *args) argb2rgba(view.buf, view.len / 4); Py_END_ALLOW_THREADS - Py_INCREF(Py_None); + Py_IncRef(Py_None); ret = Py_None; DONE: @@ -97,22 +96,27 @@ _convert_argb2rgba(PyObject *self, PyObject *args) return ret; } -static PyMethodDef ConvertMethods[] = { +static PyMethodDef _convert_methods[] = { {"argb2rgba", _convert_argb2rgba, METH_VARARGS, "Convert aRGB to RGBA in place."}, {NULL, NULL, 0, NULL} }; -static struct PyModuleDef convertmodule = { +static PyModuleDef_Slot _convert_slots[] = { + {0, NULL} +}; + +static struct PyModuleDef _convert_module = { PyModuleDef_HEAD_INIT, "_convert", NULL, 0, - ConvertMethods + _convert_methods, + _convert_slots, }; PyMODINIT_FUNC PyInit__convert(void) { - return PyModule_Create(&convertmodule); + return PyModuleDef_Init(&_convert_module); } diff --git a/openslide/_convert.pyi b/openslide/_convert.pyi new file mode 100644 index 00000000..03bf743b --- /dev/null +++ b/openslide/_convert.pyi @@ -0,0 +1,25 @@ +# +# openslide-python - Python bindings for the OpenSlide library +# +# Copyright (c) 2024 Benjamin Gilbert +# +# This library is free software; you can redistribute it and/or modify it +# under the terms of version 2.1 of the GNU Lesser General Public License +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . +# + +from typing import Protocol + +class _Buffer(Protocol): + # Python 3.12+ has collections.abc.Buffer + def __buffer__(self, flags: int) -> memoryview: ... + +def argb2rgba(buf: _Buffer) -> None: ... diff --git a/openslide/_version.py b/openslide/_version.py index 6b9c95f4..21324249 100644 --- a/openslide/_version.py +++ b/openslide/_version.py @@ -13,8 +13,7 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # """The openslide package version. @@ -22,4 +21,4 @@ This module is an implementation detail. The package version should be obtained from openslide.__version__.""" -__version__ = '1.3.0' +__version__ = '1.4.2' diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 28ec7a81..fe690b44 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -13,8 +13,7 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # """Support for Deep Zoom images. @@ -23,14 +22,21 @@ OpenSlide objects. """ +from __future__ import annotations + from io import BytesIO import math +from typing import TYPE_CHECKING from xml.etree.ElementTree import Element, ElementTree, SubElement from PIL import Image import openslide +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeGuard + class DeepZoomGenerator: """Generates Deep Zoom tiles and metadata.""" @@ -44,7 +50,13 @@ class DeepZoomGenerator: openslide.PROPERTY_NAME_BOUNDS_HEIGHT, ) - def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False): + def __init__( + self, + osr: openslide.AbstractSlide, + tile_size: int = 254, + overlap: int = 1, + limit_bounds: bool = False, + ): """Create a DeepZoomGenerator wrapping an OpenSlide object. osr: a slide object. @@ -96,10 +108,11 @@ def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False): while z_size[0] > 1 or z_size[1] > 1: z_size = tuple(max(1, int(math.ceil(z / 2))) for z in z_size) z_dimensions.append(z_size) - self._z_dimensions = tuple(reversed(z_dimensions)) + # Narrow the type, for self.level_dimensions + self._z_dimensions = self._pairs_from_n_tuples(tuple(reversed(z_dimensions))) # Tile - def tiles(z_lim): + def tiles(z_lim: int) -> int: return int(math.ceil(z_lim / self._z_t_downsample)) self._t_dimensions = tuple( @@ -110,7 +123,8 @@ def tiles(z_lim): self._dz_levels = len(self._z_dimensions) # Total downsamples for each Deep Zoom level - l0_z_downsamples = tuple( + # mypy infers this as a tuple[Any, ...] due to the ** operator + l0_z_downsamples: tuple[int, ...] = tuple( 2 ** (self._dz_levels - dz_level - 1) for dz_level in range(self._dz_levels) ) @@ -132,7 +146,7 @@ def tiles(z_lim): openslide.PROPERTY_NAME_BACKGROUND_COLOR, 'ffffff' ) - def __repr__(self): + def __repr__(self) -> str: return '{}({!r}, tile_size={!r}, overlap={!r}, limit_bounds={!r})'.format( self.__class__.__name__, self._osr, @@ -142,26 +156,26 @@ def __repr__(self): ) @property - def level_count(self): + def level_count(self) -> int: """The number of Deep Zoom levels in the image.""" return self._dz_levels @property - def level_tiles(self): - """A list of (tiles_x, tiles_y) tuples for each Deep Zoom level.""" + def level_tiles(self) -> tuple[tuple[int, int], ...]: + """A tuple of (tiles_x, tiles_y) tuples for each Deep Zoom level.""" return self._t_dimensions @property - def level_dimensions(self): - """A list of (pixels_x, pixels_y) tuples for each Deep Zoom level.""" + def level_dimensions(self) -> tuple[tuple[int, int], ...]: + """A tuple of (pixels_x, pixels_y) tuples for each Deep Zoom level.""" return self._z_dimensions @property - def tile_count(self): + def tile_count(self) -> int: """The total number of Deep Zoom tiles in the image.""" return sum(t_cols * t_rows for t_cols, t_rows in self._t_dimensions) - def get_tile(self, level, address): + def get_tile(self, level: int, address: tuple[int, int]) -> Image.Image: """Return an RGB PIL.Image for a tile. level: the Deep Zoom level. @@ -189,7 +203,9 @@ def get_tile(self, level, address): return tile - def _get_tile_info(self, dz_level, t_location): + def _get_tile_info( + self, dz_level: int, t_location: tuple[int, int] + ) -> tuple[tuple[tuple[int, int], int, tuple[int, int]], tuple[int, int]]: # Check parameters if dz_level < 0 or dz_level >= self._dz_levels: raise ValueError("Invalid level") @@ -232,18 +248,33 @@ def _get_tile_info(self, dz_level, t_location): ) # Return read_region() parameters plus tile size for final scaling + assert len(l0_location) == 2 and len(l_size) == 2 and len(z_size) == 2 return ((l0_location, slide_level, l_size), z_size) - def _l0_from_l(self, slide_level, l): + def _l0_from_l(self, slide_level: int, l: float) -> float: return self._l0_l_downsamples[slide_level] * l - def _l_from_z(self, dz_level, z): + def _l_from_z(self, dz_level: int, z: int) -> float: return self._l_z_downsamples[dz_level] * z - def _z_from_t(self, t): + def _z_from_t(self, t: int) -> int: return self._z_t_downsample * t - def get_tile_coordinates(self, level, address): + @staticmethod + def _pairs_from_n_tuples( + tuples: tuple[tuple[int, ...], ...], + ) -> tuple[tuple[int, int], ...]: + def all_pairs( + tuples: tuple[tuple[int, ...], ...], + ) -> TypeGuard[tuple[tuple[int, int], ...]]: + return all(len(t) == 2 for t in tuples) + + assert all_pairs(tuples) + return tuples + + def get_tile_coordinates( + self, level: int, address: tuple[int, int] + ) -> tuple[tuple[int, int], int, tuple[int, int]]: """Return the OpenSlide.read_region() arguments for the specified tile. Most users should call get_tile() rather than calling @@ -254,7 +285,9 @@ def get_tile_coordinates(self, level, address): tuple.""" return self._get_tile_info(level, address)[0] - def get_tile_dimensions(self, level, address): + def get_tile_dimensions( + self, level: int, address: tuple[int, int] + ) -> tuple[int, int]: """Return a (pixels_x, pixels_y) tuple for the specified tile. level: the Deep Zoom level. @@ -262,7 +295,7 @@ def get_tile_dimensions(self, level, address): tuple.""" return self._get_tile_info(level, address)[1] - def get_dzi(self, format): + def get_dzi(self, format: str) -> str: """Return a string containing the XML metadata for the .dzi file. format: the format of the individual tiles ('png' or 'jpeg')""" diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index c80d8668..6608033d 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -2,7 +2,7 @@ # openslide-python - Python bindings for the OpenSlide library # # Copyright (c) 2010-2013 Carnegie Mellon University -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -14,8 +14,7 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # """ @@ -30,8 +29,12 @@ rather than in the high-level interface.) """ +from __future__ import annotations + from ctypes import ( + CDLL, POINTER, + _Pointer, byref, c_char, c_char_p, @@ -44,35 +47,56 @@ cdll, ) from itertools import count +import os import platform +from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, cast from PIL import Image from . import _convert +if TYPE_CHECKING: + # Python 3.10+ + from typing import ParamSpec, TypeAlias + + from _convert import _Buffer + -def _load_library(): - def try_load(names): - for name in names: - try: - return cdll.LoadLibrary(name) - except OSError: - if name == names[-1]: - raise +def _load_library() -> CDLL: + try: + import openslide_bin + + return openslide_bin.libopenslide1 + except (AttributeError, ModuleNotFoundError): + pass + + def try_load(names: list[str]) -> CDLL: + try: + return cdll.LoadLibrary(names[0]) + except OSError: + remaining = names[1:] + if remaining: + # handle recursively so implicit exception chaining captures + # all the failures + return try_load(remaining) + else: + raise if platform.system() == 'Windows': try: return try_load(['libopenslide-1.dll', 'libopenslide-0.dll']) - except FileNotFoundError: + except FileNotFoundError as exc: raise ModuleNotFoundError( - "Couldn't locate OpenSlide DLL. " - "Did you call os.add_dll_directory()? " + "Couldn't locate OpenSlide DLL. " + "Try `pip install openslide-bin`, " + "or if you're using an OpenSlide binary package, " + "ensure you've called os.add_dll_directory(). " "https://openslide.org/api/python/#installing" - ) + ) from exc elif platform.system() == 'Darwin': try: return try_load(['libopenslide.1.dylib', 'libopenslide.0.dylib']) - except OSError: + except OSError as exc: # MacPorts doesn't add itself to the dyld search path, but # does add itself to the find_library() search path # (DEFAULT_LIBRARY_FALLBACK in ctypes.macholib.dyld). @@ -81,12 +105,20 @@ def try_load(names): lib = ctypes.util.find_library('openslide') if lib is None: raise ModuleNotFoundError( - "Couldn't locate OpenSlide dylib. Is OpenSlide installed " - "correctly? https://openslide.org/api/python/#installing" - ) + "Couldn't locate OpenSlide dylib. " + "Try `pip install openslide-bin`. " + "https://openslide.org/api/python/#installing" + ) from exc return cdll.LoadLibrary(lib) else: - return try_load(['libopenslide.so.1', 'libopenslide.so.0']) + try: + return try_load(['libopenslide.so.1', 'libopenslide.so.0']) + except OSError as exc: + raise ModuleNotFoundError( + "Couldn't locate OpenSlide shared library. " + "Try `pip install openslide-bin`. " + "https://openslide.org/api/python/#installing" + ) from exc _lib = _load_library() @@ -105,7 +137,7 @@ class OpenSlideVersionError(OpenSlideError): Import this from openslide rather than from openslide.lowlevel. """ - def __init__(self, minimum_version): + def __init__(self, minimum_version: str): super().__init__(f'OpenSlide >= {minimum_version} required') self.minimum_version = minimum_version @@ -120,22 +152,22 @@ class OpenSlideUnsupportedFormatError(OpenSlideError): class _OpenSlide: """Wrapper class to make sure we correctly pass an OpenSlide handle.""" - def __init__(self, ptr): + def __init__(self, ptr: c_void_p): self._as_parameter_ = ptr self._valid = True # Retain a reference to close() to avoid GC problems during # interpreter shutdown self._close = close - def __del__(self): + def __del__(self) -> None: if self._valid: self._close(self) - def invalidate(self): + def invalidate(self) -> None: self._valid = False @classmethod - def from_param(cls, obj): + def from_param(cls, obj: _OpenSlide) -> _OpenSlide: if obj.__class__ != cls: raise ValueError("Not an OpenSlide reference") if not obj._as_parameter_: @@ -148,17 +180,17 @@ def from_param(cls, obj): class _OpenSlideCache: """Wrapper class to make sure we correctly pass an OpenSlide cache.""" - def __init__(self, ptr): + def __init__(self, ptr: c_void_p): self._as_parameter_ = ptr # Retain a reference to cache_release() to avoid GC problems during # interpreter shutdown self._cache_release = cache_release - def __del__(self): + def __del__(self) -> None: self._cache_release(self) @classmethod - def from_param(cls, obj): + def from_param(cls, obj: _OpenSlideCache) -> _OpenSlideCache: if obj.__class__ != cls: raise ValueError("Not an OpenSlide cache reference") if not obj._as_parameter_: @@ -166,11 +198,33 @@ def from_param(cls, obj): return obj +if TYPE_CHECKING: + # Python 3.10+ + Filename: TypeAlias = str | bytes | os.PathLike[Any] + + +class _filename_p: + """Wrapper class to convert filename arguments to bytes.""" + + @classmethod + def from_param(cls, obj: Filename) -> bytes: + # fspath and fsencode raise TypeError on unexpected types + if platform.system() == 'Windows': + # OpenSlide 4.0.0+ requires UTF-8 on Windows + obj = os.fspath(obj) + if isinstance(obj, str): + return obj.encode('UTF-8') + else: + return obj + else: + return os.fsencode(obj) + + class _utf8_p: """Wrapper class to convert string arguments to bytes.""" @classmethod - def from_param(cls, obj): + def from_param(cls, obj: str | bytes) -> bytes: if isinstance(obj, bytes): return obj elif isinstance(obj, str): @@ -183,7 +237,7 @@ class _size_t: """Wrapper class to convert size_t arguments to c_size_t.""" @classmethod - def from_param(cls, obj): + def from_param(cls, obj: int) -> c_size_t: if not isinstance(obj, int): raise TypeError('Incorrect type') if obj < 0: @@ -191,14 +245,14 @@ def from_param(cls, obj): return c_size_t(obj) -def _load_image(buf, size): +def _load_image(buf: _Buffer, size: tuple[int, int]) -> Image.Image: '''buf must be a mutable buffer.''' _convert.argb2rgba(buf) return Image.frombuffer('RGBA', size, buf, 'raw', 'RGBA', 0, 1) # check for errors opening an image file and wrap the resulting handle -def _check_open(result, _func, _args): +def _check_open(result: int | None, _func: Any, _args: Any) -> _OpenSlide: if result is None: raise OpenSlideUnsupportedFormatError("Unsupported or missing image file") slide = _OpenSlide(c_void_p(result)) @@ -209,17 +263,17 @@ def _check_open(result, _func, _args): # prevent further operations on slide handle after it is closed -def _check_close(_result, _func, args): +def _check_close(_result: Any, _func: Any, args: tuple[_OpenSlide]) -> None: args[0].invalidate() # wrap the handle returned when creating a cache -def _check_cache_create(result, _func, _args): +def _check_cache_create(result: int, _func: Any, _args: Any) -> _OpenSlideCache: return _OpenSlideCache(c_void_p(result)) # Convert returned byte array, if present, into a string -def _check_string(result, func, _args): +def _check_string(result: Any, func: _CTypesFunc[..., Any], _args: Any) -> Any: if func.restype is c_char_p and result is not None: return result.decode('UTF-8', 'replace') else: @@ -227,7 +281,8 @@ def _check_string(result, func, _args): # check if the library got into an error state after each library call -def _check_error(result, func, args): +def _check_error(result: Any, func: Any, args: tuple[Any, ...]) -> Any: + assert isinstance(args[0], _OpenSlide) err = get_error(args[0]) if err is not None: raise OpenSlideError(err) @@ -235,7 +290,7 @@ def _check_error(result, func, args): # Convert returned NULL-terminated char** into a list of strings -def _check_name_list(result, func, args): +def _check_name_list(result: _Pointer[c_char_p], func: Any, args: Any) -> list[str]: _check_error(result, func, args) names = [] for i in count(): @@ -246,22 +301,53 @@ def _check_name_list(result, func, args): return names +class _FunctionUnavailable: + '''Standin for a missing optional function. Fails when called.''' + + def __init__(self, minimum_version: str): + self._minimum_version = minimum_version + # allow checking for availability without calling the function + self.available = False + + def __call__(self, *_args: Any) -> Any: + raise OpenSlideVersionError(self._minimum_version) + + +# gate runtime code that requires ParamSpec, Python 3.10+ +if TYPE_CHECKING: + _P = ParamSpec('_P') + _T = TypeVar('_T', covariant=True) + + class _Func(Protocol[_P, _T]): + available: bool + + def __call__(self, *args: _P.args) -> _T: ... # type: ignore[valid-type] + + class _CTypesFunc(_Func[_P, _T]): + restype: type | None + argtypes: list[type] + errcheck: _ErrCheck + + _ErrCheck: TypeAlias = ( + Callable[[Any, _CTypesFunc[..., Any], tuple[Any, ...]], Any] | None + ) + + # resolve and return an OpenSlide function with the specified properties -def _func(name, restype, argtypes, errcheck=_check_error, minimum_version=None): +def _func( + name: str, + restype: type | None, + argtypes: list[type], + errcheck: _ErrCheck = _check_error, + minimum_version: str | None = None, +) -> _Func[_P, _T]: try: - func = getattr(_lib, name) + func: _CTypesFunc[_P, _T] = getattr(_lib, name) except AttributeError: if minimum_version is None: raise - - # optional function doesn't exist; fail at runtime - def function_unavailable(*_args): - raise OpenSlideVersionError(minimum_version) - - # allow checking for availability without calling the function - function_unavailable.available = False - - return function_unavailable + else: + return _FunctionUnavailable(minimum_version) func.argtypes = argtypes func.restype = restype if errcheck is not None: @@ -270,8 +356,15 @@ def function_unavailable(*_args): return func -def _wraps_funcs(wrapped): - def decorator(f): +def _wraps_funcs( + wrapped: list[_Func[..., Any]], +) -> Callable[[Callable[_P, _T]], _Func[_P, _T]]: + def decorator(fn: Callable[_P, _T]) -> _Func[_P, _T]: + if TYPE_CHECKING: + # requires ParamSpec, Python 3.10+ + f = cast(_Func[_P, _T], fn) + else: + f = fn f.available = True for w in wrapped: f.available = f.available and w.available @@ -281,17 +374,27 @@ def decorator(f): try: - detect_vendor = _func('openslide_detect_vendor', c_char_p, [_utf8_p], _check_string) + detect_vendor: _Func[[Filename], str] = _func( + 'openslide_detect_vendor', c_char_p, [_filename_p], _check_string + ) except AttributeError: raise OpenSlideVersionError('3.4.0') -open = _func('openslide_open', c_void_p, [_utf8_p], _check_open) +open: _Func[[Filename], _OpenSlide] = _func( + 'openslide_open', c_void_p, [_filename_p], _check_open +) -close = _func('openslide_close', None, [_OpenSlide], _check_close) +close: _Func[[_OpenSlide], None] = _func( + 'openslide_close', None, [_OpenSlide], _check_close +) -get_level_count = _func('openslide_get_level_count', c_int32, [_OpenSlide]) +get_level_count: _Func[[_OpenSlide], int] = _func( + 'openslide_get_level_count', c_int32, [_OpenSlide] +) -_get_level_dimensions = _func( +_get_level_dimensions: _Func[ + [_OpenSlide, int, _Pointer[c_int64], _Pointer[c_int64]], None +] = _func( 'openslide_get_level_dimensions', None, [_OpenSlide, c_int32, POINTER(c_int64), POINTER(c_int64)], @@ -299,29 +402,33 @@ def decorator(f): @_wraps_funcs([_get_level_dimensions]) -def get_level_dimensions(slide, level): +def get_level_dimensions(slide: _OpenSlide, level: int) -> tuple[int, int]: w, h = c_int64(), c_int64() _get_level_dimensions(slide, level, byref(w), byref(h)) return w.value, h.value -get_level_downsample = _func( +get_level_downsample: _Func[[_OpenSlide, int], float] = _func( 'openslide_get_level_downsample', c_double, [_OpenSlide, c_int32] ) -get_best_level_for_downsample = _func( +get_best_level_for_downsample: _Func[[_OpenSlide, float], int] = _func( 'openslide_get_best_level_for_downsample', c_int32, [_OpenSlide, c_double] ) -_read_region = _func( - 'openslide_read_region', - None, - [_OpenSlide, POINTER(c_uint32), c_int64, c_int64, c_int32, c_int64, c_int64], +_read_region: _Func[[_OpenSlide, _Pointer[c_uint32], int, int, int, int, int], None] = ( + _func( + 'openslide_read_region', + None, + [_OpenSlide, POINTER(c_uint32), c_int64, c_int64, c_int32, c_int64, c_int64], + ) ) @_wraps_funcs([_read_region]) -def read_region(slide, x, y, level, w, h): +def read_region( + slide: _OpenSlide, x: int, y: int, level: int, w: int, h: int +) -> Image.Image: if w < 0 or h < 0: # OpenSlide would catch this, but not before we tried to allocate # a negative-size buffer @@ -336,14 +443,14 @@ def read_region(slide, x, y, level, w, h): return _load_image(buf, (w, h)) -get_icc_profile_size = _func( +get_icc_profile_size: _Func[[_OpenSlide], int] = _func( 'openslide_get_icc_profile_size', c_int64, [_OpenSlide], minimum_version='4.0.0', ) -_read_icc_profile = _func( +_read_icc_profile: _Func[[_OpenSlide, _Pointer[c_char]], None] = _func( 'openslide_read_icc_profile', None, [_OpenSlide, POINTER(c_char)], @@ -352,7 +459,7 @@ def read_region(slide, x, y, level, w, h): @_wraps_funcs([get_icc_profile_size, _read_icc_profile]) -def read_icc_profile(slide): +def read_icc_profile(slide: _OpenSlide) -> bytes | None: size = get_icc_profile_size(slide) if size == 0: return None @@ -361,24 +468,28 @@ def read_icc_profile(slide): return buf.raw -get_error = _func('openslide_get_error', c_char_p, [_OpenSlide], _check_string) +get_error: _Func[[_OpenSlide], str] = _func( + 'openslide_get_error', c_char_p, [_OpenSlide], _check_string +) -get_property_names = _func( +get_property_names: _Func[[_OpenSlide], list[str]] = _func( 'openslide_get_property_names', POINTER(c_char_p), [_OpenSlide], _check_name_list ) -get_property_value = _func( +get_property_value: _Func[[_OpenSlide, str | bytes], str] = _func( 'openslide_get_property_value', c_char_p, [_OpenSlide, _utf8_p] ) -get_associated_image_names = _func( +get_associated_image_names: _Func[[_OpenSlide], list[str]] = _func( 'openslide_get_associated_image_names', POINTER(c_char_p), [_OpenSlide], _check_name_list, ) -_get_associated_image_dimensions = _func( +_get_associated_image_dimensions: _Func[ + [_OpenSlide, str | bytes, _Pointer[c_int64], _Pointer[c_int64]], None +] = _func( 'openslide_get_associated_image_dimensions', None, [_OpenSlide, _utf8_p, POINTER(c_int64), POINTER(c_int64)], @@ -386,33 +497,41 @@ def read_icc_profile(slide): @_wraps_funcs([_get_associated_image_dimensions]) -def get_associated_image_dimensions(slide, name): +def get_associated_image_dimensions( + slide: _OpenSlide, name: str | bytes +) -> tuple[int, int]: w, h = c_int64(), c_int64() _get_associated_image_dimensions(slide, name, byref(w), byref(h)) return w.value, h.value -_read_associated_image = _func( - 'openslide_read_associated_image', None, [_OpenSlide, _utf8_p, POINTER(c_uint32)] +_read_associated_image: _Func[[_OpenSlide, str | bytes, _Pointer[c_uint32]], None] = ( + _func( + 'openslide_read_associated_image', + None, + [_OpenSlide, _utf8_p, POINTER(c_uint32)], + ) ) @_wraps_funcs([get_associated_image_dimensions, _read_associated_image]) -def read_associated_image(slide, name): +def read_associated_image(slide: _OpenSlide, name: str | bytes) -> Image.Image: w, h = get_associated_image_dimensions(slide, name) buf = (w * h * c_uint32)() _read_associated_image(slide, name, buf) return _load_image(buf, (w, h)) -get_associated_image_icc_profile_size = _func( +get_associated_image_icc_profile_size: _Func[[_OpenSlide, str | bytes], int] = _func( 'openslide_get_associated_image_icc_profile_size', c_int64, [_OpenSlide, _utf8_p], minimum_version='4.0.0', ) -_read_associated_image_icc_profile = _func( +_read_associated_image_icc_profile: _Func[ + [_OpenSlide, str | bytes, _Pointer[c_char]], None +] = _func( 'openslide_read_associated_image_icc_profile', None, [_OpenSlide, _utf8_p, POINTER(c_char)], @@ -423,7 +542,9 @@ def read_associated_image(slide, name): @_wraps_funcs( [get_associated_image_icc_profile_size, _read_associated_image_icc_profile] ) -def read_associated_image_icc_profile(slide, name): +def read_associated_image_icc_profile( + slide: _OpenSlide, name: str | bytes +) -> bytes | None: size = get_associated_image_icc_profile_size(slide, name) if size == 0: return None @@ -432,9 +553,11 @@ def read_associated_image_icc_profile(slide, name): return buf.raw -get_version = _func('openslide_get_version', c_char_p, [], _check_string) +get_version: _Func[[], str] = _func( + 'openslide_get_version', c_char_p, [], _check_string +) -cache_create = _func( +cache_create: _Func[[int], _OpenSlideCache] = _func( 'openslide_cache_create', c_void_p, [_size_t], @@ -442,14 +565,13 @@ def read_associated_image_icc_profile(slide, name): minimum_version='4.0.0', ) -set_cache = _func( +set_cache: _Func[[_OpenSlide, _OpenSlideCache], None] = _func( 'openslide_set_cache', None, [_OpenSlide, _OpenSlideCache], - None, minimum_version='4.0.0', ) -cache_release = _func( +cache_release: _Func[[_OpenSlideCache], None] = _func( 'openslide_cache_release', None, [_OpenSlideCache], None, minimum_version='4.0.0' ) diff --git a/openslide/py.typed b/openslide/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a76799db --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,91 @@ +[project] +name = "openslide-python" +maintainers = [ + {name = "OpenSlide project", email = "openslide-users@lists.andrew.cmu.edu"} +] +description = "Python interface to OpenSlide" +readme = "README.md" +license = "LGPL-2.1-only AND BSD-3-Clause AND MIT AND LicenseRef-Public-Domain" +license-files = ["COPYING.LESSER", "**/LICENSE.*"] +keywords = ["OpenSlide", "whole-slide image", "virtual slide", "library"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Healthcare Industry", + "Intended Audience :: Science/Research", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", +] +requires-python = ">= 3.9" +dependencies = ["Pillow"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://openslide.org/" +Documentation = "https://openslide.org/api/python/" +"Release notes" = "https://github.com/openslide/openslide-python/blob/main/CHANGELOG.md" +Repository = "https://github.com/openslide/openslide-python" + +[dependency-groups] +test = ["pytest >= 7"] + +[tool.setuptools] +include-package-data = false +packages = ["openslide"] + +[tool.setuptools.dynamic] +version = {attr = "openslide._version.__version__"} + +[tool.setuptools.package-data] +openslide = ["py.typed", "*.pyi"] + +[tool.black] +skip-string-normalization = true +target-version = ["py39", "py310", "py311", "py312", "py313"] + +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +[tool.codespell] +check-hidden = true +# ignore-regex = "" +# ignore-words-list = "" + +# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 +# also ignore: +# - E741 ambiguous variable name +# requires Flake8-pyproject +[tool.flake8] +max-line-length = 88 +extend-ignore = ["E203", "E741"] + +[tool.isort] +profile = "black" +force_sort_within_sections = true + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.pytest.ini_options] +minversion = "7.0" +# don't try to import openslide from the source directory, since it doesn't +# have the compiled extension module +addopts = "--import-mode importlib" +# allow tests to import common module +pythonpath = "tests" + +[tool.rstcheck] +ignore_messages = "(Hyperlink target \".*\" is not referenced\\.$)" + +[build-system] +requires = ["setuptools >= 77.0.0"] +build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 218e0493..00000000 --- a/pytest.ini +++ /dev/null @@ -1,7 +0,0 @@ -[pytest] -minversion = 6.0 -# don't try to import openslide from the source directory, since it doesn't -# have the compiled extension module -addopts = --import-mode importlib -# allow tests to import common module -pythonpath = tests diff --git a/setup.py b/setup.py index 6c182648..68147386 100644 --- a/setup.py +++ b/setup.py @@ -1,53 +1,43 @@ -import os +from pathlib import Path +import sys from setuptools import Extension, setup # Load version string -_verfile = os.path.join(os.path.dirname(__file__), 'openslide', '_version.py') -with open(_verfile) as _fh: +with open(Path(__file__).parent / 'openslide/_version.py') as _fh: exec(_fh.read()) # instantiates __version__ -with open('README.md') as _fh: - _long_description = _fh.read() +# use the Limited API on Python 3.11+; build release-specific wheels on +# older Python +_abi3 = sys.version_info >= (3, 11) setup( - name='openslide-python', - version=__version__, # noqa: F821 undefined-name __version__ - packages=[ - 'openslide', - ], ext_modules=[ - Extension('openslide._convert', ['openslide/_convert.c']), - ], - test_suite='tests', - maintainer='OpenSlide project', - maintainer_email='openslide-users@lists.andrew.cmu.edu', - description='Python interface to OpenSlide', - long_description=_long_description, - long_description_content_type='text/markdown', - license='GNU Lesser General Public License, version 2.1', - keywords='openslide whole-slide image virtual slide library', - url='https://openslide.org/', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Intended Audience :: Healthcare Industry', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Topic :: Scientific/Engineering :: Bio-Informatics', + Extension( + 'openslide._convert', + ['openslide/_convert.c'], + # hide symbols that aren't in the Limited API + define_macros=[('Py_LIMITED_API', '0x030b0000')] if _abi3 else [], + # tag extension module for Limited API + py_limited_api=_abi3, + ), ], - python_requires='>=3.8', + options={ + # tag wheel for Limited API + 'bdist_wheel': {'py_limited_api': 'cp311'} if _abi3 else {}, + }, + # + # setuptools < 61 compatibility for distro packages building from source + name='openslide-python', + version=__version__, # type: ignore[name-defined] # noqa: F821 install_requires=[ 'Pillow', ], - zip_safe=True, + packages=[ + 'openslide', + ], + package_data={ + 'openslide': ['py.typed', '*.pyi'], + }, + zip_safe=False, ) diff --git a/tests/common.py b/tests/common.py index 75e7a2dd..aaa86f7f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,10 +13,11 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + import os from pathlib import Path @@ -28,9 +29,9 @@ # environment. _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide # noqa: F401 module-imported-but-unused -def file_path(name): +def file_path(name: str) -> Path: return Path(__file__).parent / 'fixtures' / name diff --git "a/tests/fixtures/\360\237\230\220.png" "b/tests/fixtures/\360\237\230\220.png" new file mode 100644 index 00000000..fccc4fe5 Binary files /dev/null and "b/tests/fixtures/\360\237\230\220.png" differ diff --git "a/tests/fixtures/\360\237\230\220.svs" "b/tests/fixtures/\360\237\230\220.svs" new file mode 100644 index 00000000..6d113e1d Binary files /dev/null and "b/tests/fixtures/\360\237\230\220.svs" differ diff --git a/tests/test_base.py b/tests/test_base.py index 7fc983fa..9b3b3867 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -13,10 +13,11 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + import ctypes import unittest @@ -26,22 +27,26 @@ class TestLibrary(unittest.TestCase): - def test_open_slide(self): + def test_open_slide(self) -> None: with open_slide(file_path('boxes.tiff')) as osr: self.assertTrue(isinstance(osr, OpenSlide)) with open_slide(file_path('boxes.png')) as osr: self.assertTrue(isinstance(osr, ImageSlide)) - def test_lowlevel_available(self): + def test_lowlevel_available(self) -> None: '''Ensure all exported functions have an 'available' attribute.''' for name in dir(lowlevel): + attr = getattr(lowlevel, name) # ignore classes and unexported functions if name.startswith('_') or name[0].isupper(): continue + # ignore __future__ imports + if getattr(attr, '__module__', None) == '__future__': + continue # ignore random imports - if hasattr(ctypes, name) or name in ('count', 'platform'): + if hasattr(ctypes, name) or name in ('count', 'os', 'platform'): continue self.assertTrue( - hasattr(getattr(lowlevel, name), 'available'), + hasattr(attr, 'available'), f'"{name}" missing "available" attribute', ) diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index 4063ce86..33b98688 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -13,10 +13,11 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + import unittest from common import file_path @@ -25,90 +26,97 @@ from openslide.deepzoom import DeepZoomGenerator -class _BoxesDeepZoomTest: - def setUp(self): - self.osr = self.CLASS(file_path(self.FILENAME)) - self.dz = DeepZoomGenerator(self.osr, 254, 1) - - def tearDown(self): - self.osr.close() - - def test_repr(self): - self.assertEqual( - repr(self.dz), - ('DeepZoomGenerator(%r, tile_size=254, overlap=1, ' + 'limit_bounds=False)') - % self.osr, - ) - - def test_metadata(self): - self.assertEqual(self.dz.level_count, 10) - self.assertEqual(self.dz.tile_count, 11) - self.assertEqual( - self.dz.level_tiles, - ( - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (2, 1), - ), - ) - self.assertEqual( - self.dz.level_dimensions, - ( - (1, 1), - (2, 1), - (3, 2), - (5, 4), - (10, 8), - (19, 16), - (38, 32), - (75, 63), - (150, 125), - (300, 250), - ), - ) - - def test_get_tile(self): - self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) - - def test_tile_color_profile(self): - if self.CLASS is OpenSlide and not lowlevel.read_icc_profile.available: - self.skipTest("requires OpenSlide 4.0.0") - self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info['icc_profile']), 588) - - def test_get_tile_bad_level(self): - self.assertRaises(ValueError, lambda: self.dz.get_tile(-1, (0, 0))) - self.assertRaises(ValueError, lambda: self.dz.get_tile(10, (0, 0))) - - def test_get_tile_bad_address(self): - self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (-1, 0))) - self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (1, 0))) - - def test_get_tile_coordinates(self): - self.assertEqual( - self.dz.get_tile_coordinates(9, (1, 0)), ((253, 0), 0, (47, 250)) - ) - - def test_get_tile_dimensions(self): - self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) - - def test_get_dzi(self): - self.assertTrue( - 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') - ) - - -class TestSlideDeepZoom(_BoxesDeepZoomTest, unittest.TestCase): +class _Abstract: + # nested class to prevent the test runner from finding it + class BoxesDeepZoomTest(unittest.TestCase): + CLASS: type | None = None + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.CLASS is not None + assert self.FILENAME is not None + self.osr = self.CLASS(file_path(self.FILENAME)) + self.dz = DeepZoomGenerator(self.osr, 254, 1) + + def tearDown(self) -> None: + self.osr.close() + + def test_repr(self) -> None: + self.assertEqual( + repr(self.dz), + 'DeepZoomGenerator(%r, tile_size=254, overlap=1, limit_bounds=False)' + % self.osr, + ) + + def test_metadata(self) -> None: + self.assertEqual(self.dz.level_count, 10) + self.assertEqual(self.dz.tile_count, 11) + self.assertEqual( + self.dz.level_tiles, + ( + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (2, 1), + ), + ) + self.assertEqual( + self.dz.level_dimensions, + ( + (1, 1), + (2, 1), + (3, 2), + (5, 4), + (10, 8), + (19, 16), + (38, 32), + (75, 63), + (150, 125), + (300, 250), + ), + ) + + def test_get_tile(self) -> None: + self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) + + def test_tile_color_profile(self) -> None: + if self.CLASS is OpenSlide and not lowlevel.read_icc_profile.available: + self.skipTest("requires OpenSlide 4.0.0") + self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info['icc_profile']), 588) + + def test_get_tile_bad_level(self) -> None: + self.assertRaises(ValueError, lambda: self.dz.get_tile(-1, (0, 0))) + self.assertRaises(ValueError, lambda: self.dz.get_tile(10, (0, 0))) + + def test_get_tile_bad_address(self) -> None: + self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (-1, 0))) + self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (1, 0))) + + def test_get_tile_coordinates(self) -> None: + self.assertEqual( + self.dz.get_tile_coordinates(9, (1, 0)), ((253, 0), 0, (47, 250)) + ) + + def test_get_tile_dimensions(self) -> None: + self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) + + def test_get_dzi(self) -> None: + self.assertTrue( + 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') + ) + + +class TestSlideDeepZoom(_Abstract.BoxesDeepZoomTest): CLASS = OpenSlide FILENAME = 'boxes.tiff' -class TestImageDeepZoom(_BoxesDeepZoomTest, unittest.TestCase): +class TestImageDeepZoom(_Abstract.BoxesDeepZoomTest): CLASS = ImageSlide FILENAME = 'boxes.png' diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index d0e200c9..dd3fe6f9 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -1,7 +1,7 @@ # # openslide-python - Python bindings for the OpenSlide library # -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -13,10 +13,12 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + +import sys import unittest from PIL import Image @@ -26,57 +28,77 @@ class TestImageWithoutOpening(unittest.TestCase): - def test_detect_format(self): + def test_detect_format(self) -> None: self.assertTrue(ImageSlide.detect_format(file_path('__missing_file')) is None) self.assertTrue(ImageSlide.detect_format(file_path('../setup.py')) is None) self.assertEqual(ImageSlide.detect_format(file_path('boxes.png')), 'PNG') - def test_open(self): + def test_open(self) -> None: self.assertRaises(OSError, lambda: ImageSlide(file_path('__does_not_exist'))) self.assertRaises(OSError, lambda: ImageSlide(file_path('../setup.py'))) - def test_open_image(self): + def test_open_image(self) -> None: # passing PIL.Image to ImageSlide with Image.open(file_path('boxes.png')) as img: with ImageSlide(img) as osr: self.assertEqual(osr.dimensions, (300, 250)) self.assertEqual(repr(osr), 'ImageSlide(%r)' % img) - def test_operations_on_closed_handle(self): + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self) -> None: + path = file_path('😐.png') + for arg in path, str(path): + self.assertEqual(ImageSlide.detect_format(arg), 'PNG') + self.assertEqual(ImageSlide(arg).dimensions, (300, 250)) + + def test_unicode_path_bytes(self) -> None: + arg = str(file_path('😐.png')).encode('UTF-8') + self.assertEqual(ImageSlide.detect_format(arg), 'PNG') + self.assertEqual(ImageSlide(arg).dimensions, (300, 250)) + + def test_operations_on_closed_handle(self) -> None: with Image.open(file_path('boxes.png')) as img: osr = ImageSlide(img) osr.close() self.assertRaises( - AttributeError, lambda: osr.read_region((0, 0), 0, (100, 100)) + ValueError, lambda: osr.read_region((0, 0), 0, (100, 100)) ) + self.assertRaises(ValueError, lambda: osr.level_dimensions) # If an Image is passed to the constructor, ImageSlide.close() # shouldn't close it self.assertEqual(img.getpixel((0, 0)), 3) - def test_context_manager(self): + def test_context_manager(self) -> None: osr = ImageSlide(file_path('boxes.png')) with osr: pass - self.assertRaises( - AttributeError, lambda: osr.read_region((0, 0), 0, (100, 100)) - ) + self.assertRaises(ValueError, lambda: osr.read_region((0, 0), 0, (100, 100))) + self.assertRaises(ValueError, lambda: osr.level_dimensions) + +class _Abstract: + # nested class to prevent the test runner from finding it + class SlideTest(unittest.TestCase): + FILENAME: str | None = None -class _SlideTest: - def setUp(self): - self.osr = ImageSlide(file_path(self.FILENAME)) + def setUp(self) -> None: + assert self.FILENAME is not None + self.osr = ImageSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self) -> None: + self.osr.close() -class TestImage(_SlideTest, unittest.TestCase): +class TestImage(_Abstract.SlideTest): FILENAME = 'boxes.png' - def test_repr(self): + def test_repr(self) -> None: self.assertEqual(repr(self.osr), 'ImageSlide(%r)' % file_path('boxes.png')) - def test_metadata(self): + def test_metadata(self) -> None: self.assertEqual(self.osr.level_count, 1) self.assertEqual(self.osr.level_dimensions, ((300, 250),)) self.assertEqual(self.osr.dimensions, (300, 250)) @@ -88,7 +110,8 @@ def test_metadata(self): self.assertEqual(self.osr.properties, {}) self.assertEqual(self.osr.associated_images, {}) - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') self.assertEqual( len(self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile']), 588 @@ -97,37 +120,37 @@ def test_color_profile(self): len(self.osr.get_thumbnail((100, 100)).info['icc_profile']), 588 ) - def test_read_region(self): + def test_read_region(self) -> None: self.assertEqual( self.osr.read_region((-10, -10), 0, (400, 400)).size, (400, 400) ) - def test_read_region_size_dimension_zero(self): + def test_read_region_size_dimension_zero(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 0, (400, 0)).size, (400, 0)) - def test_read_region_bad_level(self): + def test_read_region_bad_level(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 1, (100, 100)) ) - def test_read_region_bad_size(self): + def test_read_region_bad_size(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 0, (400, -5)) ) - def test_thumbnail(self): + def test_thumbnail(self) -> None: self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83)) @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_set_cache(self): + def test_set_cache(self) -> None: self.osr.set_cache(OpenSlideCache(64 << 10)) self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400)) -class TestNoIccImage(_SlideTest, unittest.TestCase): +class TestNoIccImage(_Abstract.SlideTest): FILENAME = 'boxes-no-icc.png' - def test_color_profile(self): + def test_color_profile(self) -> None: self.assertIsNone(self.osr.color_profile) self.assertNotIn( 'icc_profile', self.osr.read_region((0, 0), 0, (100, 100)).info diff --git a/tests/test_openslide.py b/tests/test_openslide.py index f6c0e01f..26077efe 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -1,7 +1,7 @@ # # openslide-python - Python bindings for the OpenSlide library # -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -13,16 +13,17 @@ # License for more details. # # You should have received a copy of the GNU Lesser General Public License -# along with this library; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# along with this library. If not, see . # +from __future__ import annotations + from ctypes import ArgumentError import re import sys +from typing import Any import unittest -from PIL import Image from common import file_path from openslide import ( @@ -36,36 +37,59 @@ class TestCache(unittest.TestCase): @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_create_cache(self): + def test_create_cache(self) -> None: OpenSlideCache(0) OpenSlideCache(1) OpenSlideCache(4 << 20) self.assertRaises(ArgumentError, lambda: OpenSlideCache(-1)) - self.assertRaises(ArgumentError, lambda: OpenSlideCache(1.3)) + self.assertRaises( + ArgumentError, lambda: OpenSlideCache(1.3) # type: ignore[arg-type] + ) class TestSlideWithoutOpening(unittest.TestCase): - def test_detect_format(self): + def test_detect_format(self) -> None: self.assertTrue(OpenSlide.detect_format(file_path('__missing_file')) is None) self.assertTrue(OpenSlide.detect_format(file_path('../setup.py')) is None) self.assertEqual( OpenSlide.detect_format(file_path('boxes.tiff')), 'generic-tiff' ) - def test_open(self): + def test_open(self) -> None: self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('__does_not_exist') ) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('setup.py') ) - self.assertRaises(OpenSlideUnsupportedFormatError, lambda: OpenSlide(None)) - self.assertRaises(OpenSlideUnsupportedFormatError, lambda: OpenSlide(3)) + self.assertRaises( + ArgumentError, + lambda: OpenSlide(None), # type: ignore[arg-type] + ) + self.assertRaises( + ArgumentError, + lambda: OpenSlide(3), # type: ignore[arg-type] + ) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('unopenable.tiff') ) - def test_operations_on_closed_handle(self): + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self) -> None: + path = file_path('😐.svs') + for arg in path, str(path): + self.assertEqual(OpenSlide.detect_format(arg), 'aperio') + self.assertEqual(OpenSlide(arg).dimensions, (16, 16)) + + def test_unicode_path_bytes(self) -> None: + arg = str(file_path('😐.svs')).encode('UTF-8') + self.assertEqual(OpenSlide.detect_format(arg), 'aperio') + self.assertEqual(OpenSlide(arg).dimensions, (16, 16)) + + def test_operations_on_closed_handle(self) -> None: osr = OpenSlide(file_path('boxes.tiff')) props = osr.properties associated = osr.associated_images @@ -75,28 +99,33 @@ def test_operations_on_closed_handle(self): self.assertRaises(ArgumentError, lambda: props['openslide.vendor']) self.assertRaises(ArgumentError, lambda: associated['label']) - def test_context_manager(self): + def test_context_manager(self) -> None: osr = OpenSlide(file_path('boxes.tiff')) with osr: self.assertEqual(osr.level_count, 4) self.assertRaises(ArgumentError, lambda: osr.level_count) -class _SlideTest: - def setUp(self): - self.osr = OpenSlide(file_path(self.FILENAME)) +class _Abstract: + # nested class to prevent the test runner from finding it + class SlideTest(unittest.TestCase): + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.FILENAME is not None + self.osr = OpenSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self) -> None: + self.osr.close() -class TestSlide(_SlideTest, unittest.TestCase): +class TestSlide(_Abstract.SlideTest): FILENAME = 'boxes.tiff' - def test_repr(self): + def test_repr(self) -> None: self.assertEqual(repr(self.osr), 'OpenSlide(%r)' % file_path('boxes.tiff')) - def test_basic_metadata(self): + def test_basic_metadata(self) -> None: self.assertEqual(self.osr.level_count, 4) self.assertEqual( self.osr.level_dimensions, ((300, 250), (150, 125), (75, 62), (37, 31)) @@ -112,7 +141,7 @@ def test_basic_metadata(self): self.assertEqual(self.osr.get_best_level_for_downsample(3), 1) self.assertEqual(self.osr.get_best_level_for_downsample(37), 3) - def test_properties(self): + def test_properties(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'generic-tiff') self.assertRaises(KeyError, lambda: self.osr.properties['__does_not_exist']) # test __len__ and __iter__ @@ -126,7 +155,8 @@ def test_properties(self): @unittest.skipUnless( lowlevel.read_icc_profile.available, "requires OpenSlide 4.0.0" ) - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') self.assertEqual( len(self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile']), 588 @@ -135,51 +165,49 @@ def test_color_profile(self): len(self.osr.get_thumbnail((100, 100)).info['icc_profile']), 588 ) - def test_read_region(self): + def test_read_region(self) -> None: self.assertEqual( self.osr.read_region((-10, -10), 1, (400, 400)).size, (400, 400) ) - def test_read_region_size_dimension_zero(self): + def test_read_region_size_dimension_zero(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 1, (400, 0)).size, (400, 0)) - def test_read_region_bad_level(self): + def test_read_region_bad_level(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 4, (100, 100)).size, (100, 100)) - def test_read_region_bad_size(self): + def test_read_region_bad_size(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 1, (400, -5)) ) @unittest.skipIf(sys.maxsize < 1 << 32, '32-bit Python') - # Broken on Pillow < 6.2.0. - # https://github.com/python-pillow/Pillow/issues/3963 - @unittest.skipIf( - [int(i) for i in getattr(Image, '__version__', '0').split('.')] < [6, 2, 0], - 'broken on Pillow < 6.2.0', - ) # Disabled to avoid OOM killer on small systems, since the stdlib # doesn't provide a way to find out how much RAM we have - def _test_read_region_2GB(self): + def _test_read_region_2GB(self) -> None: self.assertEqual( self.osr.read_region((1000, 1000), 0, (32768, 16384)).size, (32768, 16384) ) - def test_thumbnail(self): + def test_thumbnail(self) -> None: self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83)) @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_set_cache(self): + def test_set_cache(self) -> None: self.osr.set_cache(OpenSlideCache(64 << 10)) self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400)) - self.assertRaises(TypeError, lambda: self.osr.set_cache(None)) - self.assertRaises(TypeError, lambda: self.osr.set_cache(3)) + self.assertRaises( + TypeError, lambda: self.osr.set_cache(None) # type: ignore[arg-type] + ) + self.assertRaises( + TypeError, lambda: self.osr.set_cache(3) # type: ignore[arg-type] + ) -class TestAperioSlide(_SlideTest, unittest.TestCase): +class TestAperioSlide(_Abstract.SlideTest): FILENAME = 'small.svs' - def test_associated_images(self): + def test_associated_images(self) -> None: self.assertEqual(self.osr.associated_images['thumbnail'].size, (16, 16)) self.assertRaises(KeyError, lambda: self.osr.associated_images['__missing']) # test __len__ and __iter__ @@ -188,7 +216,7 @@ def test_associated_images(self): len(self.osr.associated_images), ) - def mangle_repr(o): + def mangle_repr(o: Any) -> str: return re.sub('0x[0-9a-fA-F]+', '(mangled)', repr(o)) self.assertEqual( @@ -196,7 +224,7 @@ def mangle_repr(o): '<_AssociatedImageMap %s>' % mangle_repr(dict(self.osr.associated_images)), ) - def test_color_profile(self): + def test_color_profile(self) -> None: self.assertIsNone(self.osr.color_profile) self.assertNotIn( 'icc_profile', self.osr.read_region((0, 0), 0, (100, 100)).info @@ -210,10 +238,11 @@ def test_color_profile(self): @unittest.skipUnless( lowlevel.read_associated_image_icc_profile.available, "requires OpenSlide 4.0.0" ) -class TestDicomSlide(_SlideTest, unittest.TestCase): +class TestDicomSlide(_Abstract.SlideTest): FILENAME = 'boxes_0.dcm' - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') main_profile = self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile'] associated_profile = self.osr.associated_images['thumbnail'].info['icc_profile'] @@ -222,10 +251,10 @@ def test_color_profile(self): self.assertIs(main_profile, associated_profile) -class TestUnreadableSlide(_SlideTest, unittest.TestCase): +class TestUnreadableSlide(_Abstract.SlideTest): FILENAME = 'unreadable.svs' - def test_read_bad_region(self): + def test_read_bad_region(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'aperio') self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 0, (16, 16)) @@ -235,7 +264,7 @@ def test_read_bad_region(self): OpenSlideError, lambda: self.osr.properties['openslide.vendor'] ) - def test_read_bad_associated_image(self): + def test_read_bad_associated_image(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'aperio') # Prints "JPEGLib: Bogus marker length." to stderr due to # https://github.com/openslide/openslide/issues/36