From 033020e39bbb2df5d0a35d07d24d0b4cc878fce0 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 22 Jul 2023 17:04:07 -0400 Subject: [PATCH 001/137] pytest: require 7.0 for pythonpath setting Signed-off-by: Benjamin Gilbert --- pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 218e0493..8581b237 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -minversion = 6.0 +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 From 4708cdefc85bfb3276d82a2e0aa204a603ea421b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 28 Sep 2023 00:13:37 -0500 Subject: [PATCH 002/137] workflows/dco-report: use bot token Update for https://github.com/openslide/.github/pull/3. Signed-off-by: Benjamin Gilbert --- .github/workflows/dco-report.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dco-report.yml b/.github/workflows/dco-report.yml index c4e728f9..2b0e84b5 100644 --- a/.github/workflows/dco-report.yml +++ b/.github/workflows/dco-report.yml @@ -6,10 +6,9 @@ on: types: - completed -permissions: - pull-requests: write - jobs: comment: name: Organization uses: openslide/.github/.github/workflows/dco-report.yml@main + secrets: + OPENSLIDE_BOT_TOKEN: ${{ secrets.OPENSLIDE_BOT_TOKEN }} From 6e196158a950bf0a154d95a2738a2712dab7d0f7 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 28 Sep 2023 01:38:54 -0500 Subject: [PATCH 003/137] workflows/dco-report: drop permissions Ensure the workflow doesn't get write access in forks configured to give it by default. Update for https://github.com/openslide/.github/pull/4. Signed-off-by: Benjamin Gilbert --- .github/workflows/dco-report.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/dco-report.yml b/.github/workflows/dco-report.yml index 2b0e84b5..df3e8da4 100644 --- a/.github/workflows/dco-report.yml +++ b/.github/workflows/dco-report.yml @@ -6,6 +6,9 @@ on: types: - completed +permissions: + contents: none + jobs: comment: name: Organization From ee3042c0a6cefdfc9aac5534534f7a557a79f20d Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 29 Sep 2023 17:45:46 -0500 Subject: [PATCH 004/137] workflows: update actions/checkout to v4 No breaking changes. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c0ca8656..936ed439 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -18,7 +18,7 @@ jobs: name: Rerun pre-commit checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: '3.11' @@ -33,7 +33,7 @@ jobs: python-version: [3.8, 3.9, "3.10", "3.11"] 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 with: @@ -73,7 +73,7 @@ jobs: python-arch: [x86, x64] steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} ${{ matrix.python-arch }} uses: actions/setup-python@v4 with: @@ -129,7 +129,7 @@ jobs: 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 with: From 7c6faeca8dbecc03b82d88037c7058f756a707a6 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 3 Oct 2023 08:48:03 -0500 Subject: [PATCH 005/137] pre-commit-config: bump versions Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b63ae05..a1043487 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -14,7 +14,7 @@ repos: exclude: '^\.github/.*\.md$' - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v3.14.0 hooks: - id: pyupgrade name: Modernize python code @@ -27,7 +27,7 @@ repos: name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 23.9.1 hooks: - id: black name: Format python code with black @@ -35,25 +35,25 @@ repos: args: ["--skip-string-normalization"] - repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 + rev: 1.16.0 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 - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 6.1.0 hooks: - id: flake8 name: Lint python code with flake8 additional_dependencies: [flake8-bugbear] - repo: https://github.com/rstcheck/rstcheck - rev: v6.0.0.post1 + rev: v6.2.0 hooks: - id: rstcheck name: Validate reStructuredText syntax From 10dd524184fb772c604eacd6d496c5fc3a97d637 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 1 Oct 2023 22:49:54 -0500 Subject: [PATCH 006/137] Add Python 3.12 setuptools is no longer installed along with Python. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 12 ++++++------ setup.py | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 936ed439..1a925ffd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.12' - uses: pre-commit/action@v3.0.0 tests: name: Tests @@ -30,7 +30,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] steps: - name: Check out repo uses: actions/checkout@v4 @@ -41,7 +41,7 @@ jobs: - name: Install Python tools run: | python -m pip install --upgrade pip - pip install jinja2 pytest + pip install jinja2 pytest setuptools - name: Install OpenSlide run: | case "${{ matrix.os }}" in @@ -69,7 +69,7 @@ jobs: shell: bash strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] python-arch: [x86, x64] steps: - name: Check out repo @@ -82,7 +82,7 @@ jobs: - name: Install Python tools run: | python -m pip install --upgrade pip - pip install flask pytest wheel + pip install flask pytest setuptools wheel # Current Pillow releases don't have 32-bit wheels # https://github.com/python-pillow/Pillow/issues/7251 pip install Pillow --only-binary=:all: @@ -133,7 +133,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.12' - name: Install Python tools run: | python -m pip install --upgrade pip diff --git a/setup.py b/setup.py index 6c182648..34a71afc 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Scientific/Engineering :: Bio-Informatics', ], python_requires='>=3.8', From 6a12163d0f71ff7be747189a36eb66d27054a571 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 8 Oct 2023 10:22:10 -0500 Subject: [PATCH 007/137] Default to default rendering intent from slide ICC profile ICC profiles don't necessarily support all rendering intents, and aren't necessarily optimized for all the intents they do support. They can also be queried for the rendering intent recommended by the profile creator. Use that by default, instead of absolute colorimetric. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 19 ++++++++----------- examples/deepzoom/deepzoom_multiserver.py | 13 ++++++++----- examples/deepzoom/deepzoom_server.py | 13 ++++++++----- examples/deepzoom/deepzoom_tile.py | 11 +++++++---- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 68cf41fc..3e01bce6 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -213,22 +213,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,15 +237,13 @@ 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 ------- diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 83c65427..267d8ee0 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -73,7 +73,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: @@ -212,6 +212,8 @@ def _get_transform(self, image): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.getDefaultIntent(image.color_profile) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -276,6 +278,7 @@ def __init__(self, relpath): '--color-mode', dest='DEEPZOOM_COLOR_MODE', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -283,11 +286,11 @@ 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( diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index 234b15bc..aa492389 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -73,7 +73,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: @@ -182,6 +182,8 @@ def get_transform(image, mode): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.getDefaultIntent(image.color_profile) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -224,6 +226,7 @@ def xfrm(img): '--color-mode', dest='DEEPZOOM_COLOR_MODE', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -231,11 +234,11 @@ 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( diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 739a40cb..68c1a3e3 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -125,6 +125,8 @@ def _get_transform(self, image): elif mode == 'embed': # embed ICC profile in tiles return lambda img: None + elif mode == 'default': + intent = ImageCms.getDefaultIntent(image.color_profile) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -356,6 +358,7 @@ def _shutdown(self): '--color-mode', dest='color_mode', choices=[ + 'default', 'absolute-colorimetric', 'perceptual', 'relative-colorimetric', @@ -363,11 +366,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( From 052fb2523bdf3b25dd45daae54d8c6be473bf277 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 8 Oct 2023 10:46:53 -0500 Subject: [PATCH 008/137] templates/release: add additional package repos The Fedora package has EPEL branches, and we now have an Ubuntu PPA. Update the PPA before sending announce mail so users can upgrade immediately. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 72b6332c..8c533e99 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -11,6 +11,7 @@ - [ ] Attach release notes to [GitHub release](https://github.com/openslide/openslide-python/releases/new); upload 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` +- [ ] Update Ubuntu PPA - [ ] Send mail to -announce and -users -- [ ] Update Fedora package +- [ ] Update Fedora and EPEL packages - [ ] Update MacPorts package From 52bb1ac2fe5e8a1ecbef8c5d7e63d3f83065babd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 8 Oct 2023 11:30:11 -0500 Subject: [PATCH 009/137] Update for release Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 6 ++++++ openslide/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efb0b02..2e80aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Notable Changes in OpenSlide Python +## Version 1.3.1, 2023-10-08 + +* docs: Document using ICC profile's default intent, not absolute colorimetric +* examples: Default to ICC profile's default intent, not absolute colorimetric +* tests: Correctly require pytest ≥ 7.0 + ## Version 1.3.0, 2023-07-22 * Support new soname in OpenSlide ≥ 4.0.0 diff --git a/openslide/_version.py b/openslide/_version.py index 6b9c95f4..2c184e31 100644 --- a/openslide/_version.py +++ b/openslide/_version.py @@ -22,4 +22,4 @@ This module is an implementation detail. The package version should be obtained from openslide.__version__.""" -__version__ = '1.3.0' +__version__ = '1.3.1' From d76c1abee6e80ee8d0e710e0e4b8d7ed5ce43267 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 11 Oct 2023 13:14:38 -0500 Subject: [PATCH 010/137] workflows: update to OpenSlide Windows release 20231011 Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 1a925ffd..92646788 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -63,7 +63,7 @@ jobs: needs: pre-commit runs-on: windows-latest env: - WINBUILD_RELEASE: 20230414 + WINBUILD_RELEASE: 20231011 defaults: run: shell: bash From c5206f69cfa529cd32811bc3b8a3d47aed4800be Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 30 Oct 2023 00:18:29 -0500 Subject: [PATCH 011/137] doc: update link to OpenSlide Windows binaries Signed-off-by: Benjamin Gilbert --- doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index 3e01bce6..102e6030 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -76,7 +76,7 @@ to a known path. Then, import ``openslide`` inside a .. _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 From a9324c39c90a3ef3d06c0bd92a93d551c95343b2 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 27 Oct 2023 00:33:01 -0500 Subject: [PATCH 012/137] workflows: drop 32-bit wheels We're planning to drop 32-bit Windows builds of OpenSlide, so 32-bit wheels will no longer be useful. Fixes: https://github.com/openslide/openslide-python/issues/232 Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 92646788..5619f03d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -70,30 +70,22 @@ jobs: strategy: matrix: python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] - python-arch: [x86, x64] steps: - name: Check out repo uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} ${{ matrix.python-arch }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 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 setuptools wheel - # Current Pillow releases don't have 32-bit wheels - # https://github.com/python-pillow/Pillow/issues/7251 - pip install Pillow --only-binary=:all: + pip install flask Pillow pytest setuptools wheel - name: Install OpenSlide 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 + zipname=openslide-win64-${WINBUILD_RELEASE} curl -LO "https://github.com/openslide/openslide-winbuild/releases/download/v${WINBUILD_RELEASE}/${zipname}.zip" 7z x ${zipname}.zip echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV From 8f7f70edc643a9aff3e43e91b0c3e766966e8e49 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 31 Oct 2023 22:57:18 -0500 Subject: [PATCH 013/137] Rename openslide-winbuild to openslide-bin Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5619f03d..d5d8287a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -63,7 +63,7 @@ jobs: needs: pre-commit runs-on: windows-latest env: - WINBUILD_RELEASE: 20231011 + BIN_RELEASE: 20231011 defaults: run: shell: bash @@ -85,8 +85,8 @@ jobs: run: | mkdir -p c:\\openslide cd c:\\openslide - zipname=openslide-win64-${WINBUILD_RELEASE} - curl -LO "https://github.com/openslide/openslide-winbuild/releases/download/v${WINBUILD_RELEASE}/${zipname}.zip" + zipname=openslide-win64-${BIN_RELEASE} + curl -LO "https://github.com/openslide/openslide-bin/releases/download/v${BIN_RELEASE}/${zipname}.zip" 7z x ${zipname}.zip echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV - name: Build wheel From e9cc7d541f8f29602e888ecfb322cdd93007b206 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 02:55:43 -0500 Subject: [PATCH 014/137] templates/release: procedure updates Update Fedora and check that it propagated to the Copr before announcing. Also announce to forum.image.sc. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 8c533e99..c480d1d0 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -12,6 +12,8 @@ - [ ] `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` - [ ] Update Ubuntu PPA -- [ ] Send mail to -announce and -users - [ ] Update Fedora and EPEL packages +- [ ] Check that [Copr package](https://copr.fedorainfracloud.org/coprs/g/openslide/openslide/builds/) built successfully +- [ ] Send mail to -announce and -users +- [ ] Post to [forum.image.sc](https://forum.image.sc/c/announcements/10) - [ ] Update MacPorts package From ef6178554d6692ee5d50d537b33aad09d6cc389b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 05:58:01 -0500 Subject: [PATCH 015/137] workflows: fail on curl error 7z will fail if given an HTTP error page, so this is only a cleanup. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d5d8287a..680042f7 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -86,7 +86,7 @@ jobs: mkdir -p c:\\openslide cd c:\\openslide zipname=openslide-win64-${BIN_RELEASE} - curl -LO "https://github.com/openslide/openslide-bin/releases/download/v${BIN_RELEASE}/${zipname}.zip" + curl -LfO "https://github.com/openslide/openslide-bin/releases/download/v${BIN_RELEASE}/${zipname}.zip" 7z x ${zipname}.zip echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV - name: Build wheel From b893ba288c257e895a2f8ca9e95cfe17661587d2 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 00:28:03 -0500 Subject: [PATCH 016/137] Switch to PEP 621 project metadata Drop obsolete test_suite and zip_safe settings. Combine multi-word keywords. Add repo and documentation URLs. PEP 621 support requires setuptools >= 61. Closes: https://github.com/openslide/openslide-python/issues/167 Signed-off-by: Benjamin Gilbert --- .gitignore | 1 - pyproject.toml | 45 +++++++++++++++++++++++++++++++++++++++++++++ setup.py | 47 ----------------------------------------------- 3 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 pyproject.toml 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/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0bb61d7b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "openslide-python" +maintainers = [ + {name = "OpenSlide project", email = "openslide-users@lists.andrew.cmu.edu"} +] +description = "Python interface to OpenSlide" +readme = "README.md" +license = {text = "GNU Lesser General Public License, version 2.1"} +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", + "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", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Bio-Informatics", +] +requires-python = ">= 3.8" +dependencies = ["Pillow"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://openslide.org/" +Documentation = "https://openslide.org/api/python/" +Repository = "https://github.com/openslide/openslide-python" + +[tool.setuptools] +packages = ["openslide"] + +[tool.setuptools.dynamic] +version = {attr = "openslide._version.__version__"} + +[build-system] +requires = ["setuptools >= 61.0.0"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 34a71afc..8f821a95 100644 --- a/setup.py +++ b/setup.py @@ -1,54 +1,7 @@ -import os - from setuptools import Extension, setup -# Load version string -_verfile = os.path.join(os.path.dirname(__file__), 'openslide', '_version.py') -with open(_verfile) as _fh: - exec(_fh.read()) # instantiates __version__ - -with open('README.md') as _fh: - _long_description = _fh.read() - 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', - 'Programming Language :: Python :: 3.12', - 'Topic :: Scientific/Engineering :: Bio-Informatics', - ], - python_requires='>=3.8', - install_requires=[ - 'Pillow', - ], - zip_safe=True, ) From 37f6fb914c617802e1e3bcf676a0e4939b809ec1 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 01:45:39 -0500 Subject: [PATCH 017/137] workflows: build with Python `build` package It's the build tool currently recommended by PyPA. Since it builds in a venv, we can stop installing some dependencies. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 680042f7..48ed8458 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -41,7 +41,7 @@ jobs: - name: Install Python tools run: | python -m pip install --upgrade pip - pip install jinja2 pytest setuptools + pip install build jinja2 pytest - name: Install OpenSlide run: | case "${{ matrix.os }}" in @@ -52,8 +52,10 @@ jobs: brew install openslide ;; esac + - name: Build wheel + run: python -m build -w - name: Install - run: pip install . + run: pip install dist/*.whl - name: Run tests run: pytest -v - name: Tile slide @@ -80,7 +82,7 @@ jobs: - name: Install Python tools run: | python -m pip install --upgrade pip - pip install flask Pillow pytest setuptools wheel + pip install build flask pytest - name: Install OpenSlide run: | mkdir -p c:\\openslide @@ -91,13 +93,13 @@ jobs: echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV - name: Build wheel run: | - python setup.py bdist_wheel + python -m build -w 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 - name: Install - run: pip install -e . + run: pip install artifacts/${basename}/*.whl - name: Run tests # Reads OPENSLIDE_PATH run: pytest -v From d6af87d4ec4ab70d0f8b7e3f75ffcd6420d546d8 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 02:11:48 -0500 Subject: [PATCH 018/137] workflows: build wheels for Linux and macOS The convert module doesn't use any libc APIs, so we can build on any Linux distro and auditwheel will tag the wheel as the oldest manylinux. auditwheel requires setuptools on Python 3.12 for distutils. Fixes: https://github.com/openslide/openslide-python/issues/126 Fixes: https://github.com/openslide/openslide-python/issues/187 Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 57 ++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 48ed8458..e293ff24 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,17 +31,45 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + include: + # Python 3.8 is too old to support universal binaries, and + # setup-python's Python 3.9 and 3.10 won't build them. Use the + # last upstream patch releases that ship with installers. + # https://github.com/actions/setup-python/issues/439#issuecomment-1247646682 + - os: macos-latest + python-version: "3.9" + upstream-python: 3.9.13 + - os: macos-latest + python-version: "3.10" + upstream-python: 3.10.11 steps: - name: Check out repo uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} + if: matrix.upstream-python == null uses: actions/setup-python@v4 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 build jinja2 pytest + pip install auditwheel build jinja2 pytest setuptools - name: Install OpenSlide run: | case "${{ matrix.os }}" in @@ -53,13 +81,36 @@ jobs: ;; esac - name: Build wheel - run: python -m build -w + run: | + python -m build -w + case "${{ matrix.os }}" in + ubuntu-*) + mkdir old + mv dist/*.whl old/ + auditwheel repair --only-plat -w dist old/*whl + ;; + macos-*) + if [ "${{ matrix.python-version }}" != 3.8 -a ! -e dist/*universal2* ]; then + echo "Wheel is not universal:" + ls dist + exit 1 + fi + esac + basename=openslide-python-wheels-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) + mkdir -p "artifacts/${basename}" + mv dist/* "artifacts/${basename}" + echo "basename=${basename}" >> $GITHUB_ENV - name: Install - run: pip install dist/*.whl + run: pip install artifacts/${basename}/*.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 wheel + uses: actions/upload-artifact@v3 + with: + name: ${{ env.basename }} + path: artifacts windows: name: Windows needs: pre-commit From 3e2c3b506df0064a73f4246de3f96cb7f4e7d5ff Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 05:57:53 -0500 Subject: [PATCH 019/137] workflows: generate sdist tarball in CI By omitting the `python -m build` -w argument in one job, verify that the source tarball can successfully build a wheel. Use that source tarball in releases rather than building it on a developer's machine. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 1 - .github/workflows/python.yml | 8 +++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index c480d1d0..9bdb55c1 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -5,7 +5,6 @@ - [ ] `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 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e293ff24..296f2a0d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -32,6 +32,9 @@ jobs: os: [ubuntu-latest, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] include: + - os: ubuntu-latest + python-version: "3.12" + sdist: sdist # Python 3.8 is too old to support universal binaries, and # setup-python's Python 3.9 and 3.10 won't build them. Use the # last upstream patch releases that ship with installers. @@ -82,7 +85,10 @@ jobs: esac - name: Build wheel run: | - python -m build -w + if [ -z "${{ matrix.sdist }}" ]; then + wheel_only=-w + fi + python -m build $wheel_only case "${{ matrix.os }}" in ubuntu-*) mkdir old From fe10747e108040da9889c43794eb8686db8ea395 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 15:23:46 -0500 Subject: [PATCH 020/137] pre-commit-config: bump versions Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1043487..7145ebb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -14,7 +14,7 @@ repos: exclude: '^\.github/.*\.md$' - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade name: Modernize python code @@ -27,7 +27,7 @@ repos: name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.1 hooks: - id: black name: Format python code with black From 83b18c75a25f89459253b6338322af0d11c183d4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 1 Nov 2023 15:33:41 -0500 Subject: [PATCH 021/137] Move isort, pytest, rstcheck configs into pyproject.toml pre-commit will not support pyproject.toml, and flake8 doesn't support it yet. Signed-off-by: Benjamin Gilbert --- .isort.cfg | 3 --- .pre-commit-config.yaml | 2 +- .rstcheck.cfg | 2 -- MANIFEST.in | 2 +- pyproject.toml | 15 +++++++++++++++ pytest.ini | 7 ------- 6 files changed, 17 insertions(+), 14 deletions(-) delete mode 100644 .isort.cfg delete mode 100644 .rstcheck.cfg delete mode 100644 pytest.ini 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 7145ebb4..6ecd122d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,7 @@ repos: hooks: - id: rstcheck name: Validate reStructuredText syntax - additional_dependencies: [sphinx] + additional_dependencies: [sphinx, toml] - repo: meta hooks: diff --git a/.rstcheck.cfg b/.rstcheck.cfg deleted file mode 100644 index 38ae5093..00000000 --- a/.rstcheck.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[rstcheck] -ignore_messages=(Hyperlink target ".*" is not referenced\.$) diff --git a/MANIFEST.in b/MANIFEST.in index b84f5159..2aa1e1d6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include *.md pytest.ini +include *.md recursive-include doc *.py *.rst recursive-include examples *.html *.js *.png *.py recursive-include tests *.dcm *.png *.py *.svs *.tiff diff --git a/pyproject.toml b/pyproject.toml index 0bb61d7b..53914a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,21 @@ packages = ["openslide"] [tool.setuptools.dynamic] version = {attr = "openslide._version.__version__"} +[tool.isort] +profile = "black" +force_sort_within_sections = 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 >= 61.0.0"] build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 8581b237..00000000 --- a/pytest.ini +++ /dev/null @@ -1,7 +0,0 @@ -[pytest] -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 From a043149939c344860830c42a237d9c0ed56602f9 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 10 Nov 2023 13:18:41 -0600 Subject: [PATCH 022/137] templates/python-bump: Trove classifiers moved to pyproject.toml Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/python-bump.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/python-bump.md b/.github/ISSUE_TEMPLATE/python-bump.md index ab02f85e..ae6467ef 100644 --- a/.github/ISSUE_TEMPLATE/python-bump.md +++ b/.github/ISSUE_TEMPLATE/python-bump.md @@ -2,7 +2,7 @@ - Update Git main - [ ] `git checkout main` - - [ ] Add classifier for new Python version to `setup.py` + - [ ] Add classifier for new Python version to `pyproject.toml' - [ ] Add new Python version to lists in `.github/workflows/python.yml` - [ ] Commit and open a PR - [ ] Merge the PR when CI passes From a68cf9328b0e6b483a5b4c826a94bbae51482837 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 10 Nov 2023 23:45:49 -0600 Subject: [PATCH 023/137] convert: use Python Limited API on Python 3.11 and above This allows us to produce forward-compatible wheels and stop building new wheels for every Python minor release. The buffer protocol API was added to the Limited API in 3.11, so we still need version-specific wheels for releases older than that. Replace a couple functions that aren't in the Stable API with equivalents that are. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/python-bump.md | 11 +---------- .github/workflows/python.yml | 8 ++++++++ openslide/_convert.c | 4 ++-- setup.py | 21 ++++++++++++++++++++- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/python-bump.md b/.github/ISSUE_TEMPLATE/python-bump.md index ae6467ef..0b0f6b25 100644 --- a/.github/ISSUE_TEMPLATE/python-bump.md +++ b/.github/ISSUE_TEMPLATE/python-bump.md @@ -1,4 +1,4 @@ -# Adding wheels for a new Python release +# Adding a new Python release - Update Git main - [ ] `git checkout main` @@ -7,14 +7,5 @@ - [ ] 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/workflows/python.yml b/.github/workflows/python.yml index 296f2a0d..00f531c7 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -106,6 +106,9 @@ jobs: mkdir -p "artifacts/${basename}" mv dist/* "artifacts/${basename}" echo "basename=${basename}" >> $GITHUB_ENV + # save version-specific wheels and oldest abi3 wheel + python -c 'import sys + if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install run: pip install artifacts/${basename}/*.whl - name: Run tests @@ -113,6 +116,7 @@ jobs: - name: Tile slide run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs - name: Archive wheel + if: env.archive_wheel uses: actions/upload-artifact@v3 with: name: ${{ env.basename }} @@ -155,6 +159,9 @@ jobs: mkdir -p "artifacts/${basename}" mv dist/*.whl "artifacts/${basename}" echo "basename=${basename}" >> $GITHUB_ENV + # save version-specific wheels and oldest abi3 wheel + python -c 'import sys + if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install run: pip install artifacts/${basename}/*.whl - name: Run tests @@ -170,6 +177,7 @@ jobs: # Reads OPENSLIDE_PATH run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs - name: Archive wheel + if: env.archive_wheel uses: actions/upload-artifact@v3 with: name: ${{ env.basename }} diff --git a/openslide/_convert.c b/openslide/_convert.c index 8c367315..6b0bf838 100644 --- a/openslide/_convert.c +++ b/openslide/_convert.c @@ -89,7 +89,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: @@ -114,5 +114,5 @@ static struct PyModuleDef convertmodule = { PyMODINIT_FUNC PyInit__convert(void) { - return PyModule_Create(&convertmodule); + return PyModule_Create2(&convertmodule, PYTHON_API_VERSION); } diff --git a/setup.py b/setup.py index 8f821a95..07c9c702 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,26 @@ +import sys + from setuptools import Extension, setup +# use the Limited API on Python 3.11+; build release-specific wheels on +# older Python +_abi3 = sys.version_info >= (3, 11) + setup( ext_modules=[ - Extension('openslide._convert', ['openslide/_convert.c']), + 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, + ), ], + options={ + # tag wheel for Limited API + 'bdist_wheel': {'py_limited_api': 'cp311'} + if _abi3 + else {}, + }, ) From 183576ace0eeb197bc475b4632b44a1fdcb36b1d Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 12 Nov 2023 06:10:00 -0600 Subject: [PATCH 024/137] pre-commit-config: small improvements Add some additional hooks. Drop unnecessary exclusions of GitHub issue templates. Python 3.8 is now the minimum supported version. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ecd122d..503c9f78 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,18 +7,19 @@ repos: hooks: - id: check-added-large-files - id: check-merge-conflict + - 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: v3.15.0 hooks: - id: pyupgrade name: Modernize python code - args: ["--py37-plus"] + args: ["--py38-plus"] - repo: https://github.com/PyCQA/isort rev: 5.12.0 From 8c0e776259512e4a7bc4373e8ce69db56c4fd7ee Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 14 Nov 2023 22:50:55 -0600 Subject: [PATCH 025/137] pre-commit: move black settings to pyproject.toml Also stop explicitly specifying that black should be run under Python 3; it's redundant. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 2 -- pyproject.toml | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 503c9f78..c6cf8563 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,8 +32,6 @@ repos: hooks: - id: black name: Format python code with black - language_version: python3 - args: ["--skip-string-normalization"] - repo: https://github.com/asottile/blacken-docs rev: 1.16.0 diff --git a/pyproject.toml b/pyproject.toml index 53914a58..943358a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ packages = ["openslide"] [tool.setuptools.dynamic] version = {attr = "openslide._version.__version__"} +[tool.black] +skip-string-normalization = true + [tool.isort] profile = "black" force_sort_within_sections = true From 0c4a99c16031e1900ddcf93f2f9c41b8eed7d29c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 14 Nov 2023 22:52:34 -0600 Subject: [PATCH 026/137] pre-commit: specify Python target versions to black Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/python-bump.md | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/python-bump.md b/.github/ISSUE_TEMPLATE/python-bump.md index 0b0f6b25..6daf89ea 100644 --- a/.github/ISSUE_TEMPLATE/python-bump.md +++ b/.github/ISSUE_TEMPLATE/python-bump.md @@ -2,7 +2,7 @@ - Update Git main - [ ] `git checkout main` - - [ ] Add classifier for new Python version to `pyproject.toml' + - [ ] In `pyproject.toml`, add classifier for new Python version and update `tool.black.target-version` - [ ] Add new Python version to lists in `.github/workflows/python.yml` - [ ] Commit and open a PR - [ ] Merge the PR when CI passes diff --git a/pyproject.toml b/pyproject.toml index 943358a0..699b5925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ version = {attr = "openslide._version.__version__"} [tool.black] skip-string-normalization = true +target-version = ["py38", "py39", "py310", "py311", "py312"] [tool.isort] profile = "black" From 664ebe4892da0129268ee37c92d88d74c0858390 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Nov 2023 11:53:19 -0600 Subject: [PATCH 027/137] pre-commit: add TOML checker for pyproject.toml Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c6cf8563..d3cf4e80 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,7 @@ repos: 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 From 5cf0b40640b5201cf3386f90c9f6bc7a06edd24e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 11:50:46 -0600 Subject: [PATCH 028/137] {README,CHANGELOG}: avoid HTML entities Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 16 ++++++++-------- README.md | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e80aca8..ac7c22af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,18 @@ * docs: Document using ICC profile's default intent, not absolute colorimetric * examples: Default to ICC profile's default intent, not absolute colorimetric -* tests: Correctly require pytest ≥ 7.0 +* tests: Correctly require pytest ≥ 7.0 ## Version 1.3.0, 2023-07-22 -* Support new soname in OpenSlide ≥ 4.0.0 +* Support new soname in OpenSlide ≥ 4.0.0 * Drop support for Python 3.7 * Expose color management profiles where available * Notate available OpenSlide functions in low-level API * docs: Update OpenSlide 3.5.0 references to 4.0.0 * docs: Consolidate license information -* docs: Drop support for building with Sphinx < 1.6 -* examples: Fix startup failure with Flask ≥ 2.3.0 +* docs: Drop support for building with Sphinx \< 1.6 +* examples: Fix startup failure with Flask ≥ 2.3.0 * examples: Transform to sRGB (with absolute colorimetric intent) by default * examples: Update OpenSeadragon to 4.1.0 * examples: Correctly import `openslide` on Windows if `OPENSLIDE_PATH` not set @@ -23,7 +23,7 @@ ## Version 1.2.0, 2022-06-17 -* Drop support for Python < 3.7 +* Drop support for Python \< 3.7 * Support cache customization with OpenSlide 3.5.0 * Improve pixel read performance * Clarify exception raised on Windows or macOS when OpenSlide can't be found @@ -39,7 +39,7 @@ ## Version 1.1.2, 2020-09-13 -* Fix install with setuptools ≥ 46 +* Fix install with setuptools ≥ 46 * Fix `ImportError` with Python 3.9 * Fix docs build with Sphinx 2.x * Remove `--without-performance` install option @@ -48,11 +48,11 @@ * Change default Deep Zoom tile size to 254 pixels * Fix image reading with Pillow 3.x when installed `--without-performance` -* Fix reading ≥ 229 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 diff --git a/README.md b/README.md index 26d8bf79..ef0f4493 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ OpenSlide can read virtual slides in several formats: ## Requirements -* Python ≥ 3.8 -* OpenSlide ≥ 3.4.0 +* Python ≥ 3.8 +* OpenSlide ≥ 3.4.0 * Pillow From 69664e31b423a8a73374e9e99a77f0d236fc7410 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 11:05:49 -0600 Subject: [PATCH 029/137] workflows: assign names to pre-commit steps Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 00f531c7..62aaa5cf 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -18,11 +18,14 @@ jobs: name: Rerun pre-commit checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - name: Check out repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 with: python-version: '3.12' - - uses: pre-commit/action@v3.0.0 + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.0 tests: name: Tests needs: pre-commit From 90e4dfb7d3e300325bb088cd75014d37377c7187 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 10:55:53 -0600 Subject: [PATCH 030/137] workflows: add separation between job definitions Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 62aaa5cf..5851a23f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -26,6 +26,7 @@ jobs: python-version: '3.12' - name: Run pre-commit hooks uses: pre-commit/action@v3.0.0 + tests: name: Tests needs: pre-commit @@ -124,6 +125,7 @@ jobs: with: name: ${{ env.basename }} path: artifacts + windows: name: Windows needs: pre-commit @@ -185,6 +187,7 @@ jobs: with: name: ${{ env.basename }} path: artifacts + docs: name: Docs needs: pre-commit From 979ad550d2a9b3abd37896b796f19dc49041659a Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 10:06:49 -0600 Subject: [PATCH 031/137] workflows: rename wheels GitHub Actions artifact to dist Since 3e2c3b506d, it also includes a source tarball. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 4 ++-- .github/workflows/python.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 9bdb55c1..7485e217 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -3,8 +3,8 @@ - [ ] 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/` +- [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions) for the tag; download its dist and docs artifacts +- [ ] `unzip /path/to/downloaded/openslide-python-dist.zip && mv openslide-python-dist-*/* dist/` - [ ] `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 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5851a23f..2b086b01 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -87,7 +87,7 @@ jobs: brew install openslide ;; esac - - name: Build wheel + - name: Build dist run: | if [ -z "${{ matrix.sdist }}" ]; then wheel_only=-w @@ -106,7 +106,7 @@ jobs: exit 1 fi esac - basename=openslide-python-wheels-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) + basename=openslide-python-dist-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) mkdir -p "artifacts/${basename}" mv dist/* "artifacts/${basename}" echo "basename=${basename}" >> $GITHUB_ENV @@ -119,7 +119,7 @@ jobs: run: pytest -v - name: Tile slide run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs - - name: Archive wheel + - name: Archive dist if: env.archive_wheel uses: actions/upload-artifact@v3 with: @@ -160,7 +160,7 @@ jobs: - name: Build wheel run: | python -m build -w - basename=openslide-python-wheels-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) + basename=openslide-python-dist-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) mkdir -p "artifacts/${basename}" mv dist/*.whl "artifacts/${basename}" echo "basename=${basename}" >> $GITHUB_ENV From 5d8407542e39b2264484ce1e77816922042205df Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 10:38:14 -0600 Subject: [PATCH 032/137] workflows: remember to archive source tarball It's generated on a platform where we aren't archiving wheels. Fixes: a68cf9328b ("convert: use Python Limited API on Python 3.11 and above") Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 2b086b01..7a2b4762 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -119,8 +119,11 @@ jobs: run: pytest -v - name: Tile slide run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs + - name: Remove wheel from upload + if: matrix.sdist && ! env.archive_wheel + run: rm artifacts/$basename/*.whl - name: Archive dist - if: env.archive_wheel + if: matrix.sdist || env.archive_wheel uses: actions/upload-artifact@v3 with: name: ${{ env.basename }} From d309ca30760b4c6d1103f176fcbe854056b3c1c1 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 10:10:58 -0600 Subject: [PATCH 033/137] templates/release: link to the specific workflow we want Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 7485e217..dc26b64a 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -3,7 +3,7 @@ - [ ] 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 dist and docs artifacts +- [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions/workflows/python.yml) for the tag; download its dist and docs artifacts - [ ] `unzip /path/to/downloaded/openslide-python-dist.zip && mv openslide-python-dist-*/* dist/` - [ ] `twine upload dist/*` - [ ] Recompress tarball with `xz` From fd39a83debdfc1b2956d38a9c3673f990d81998f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 11:17:05 -0600 Subject: [PATCH 034/137] workflows: calculate artifact basenames in pre-commit job Avoid redundant name calculations. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 38 +++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7a2b4762..04505fac 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -17,6 +17,9 @@ 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: - name: Check out repo uses: actions/checkout@v4 @@ -26,6 +29,12 @@ jobs: python-version: '3.12' - name: Run pre-commit hooks uses: pre-commit/action@v3.0.0 + - 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 @@ -106,27 +115,25 @@ jobs: exit 1 fi esac - basename=openslide-python-dist-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) - mkdir -p "artifacts/${basename}" - mv dist/* "artifacts/${basename}" - echo "basename=${basename}" >> $GITHUB_ENV + mkdir -p "artifacts/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/* "artifacts/${{ needs.pre-commit.outputs.dist-base }}" # save version-specific wheels and oldest abi3 wheel python -c 'import sys if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install artifacts/${basename}/*.whl + run: pip install artifacts/${{ 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: Remove wheel from upload if: matrix.sdist && ! env.archive_wheel - run: rm artifacts/$basename/*.whl + run: rm artifacts/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Archive dist if: matrix.sdist || env.archive_wheel uses: actions/upload-artifact@v3 with: - name: ${{ env.basename }} + name: ${{ needs.pre-commit.outputs.dist-base }} path: artifacts windows: @@ -163,15 +170,13 @@ jobs: - name: Build wheel run: | python -m build -w - basename=openslide-python-dist-$GITHUB_RUN_NUMBER-$(echo $GITHUB_SHA | cut -c-10) - mkdir -p "artifacts/${basename}" - mv dist/*.whl "artifacts/${basename}" - echo "basename=${basename}" >> $GITHUB_ENV + mkdir -p "artifacts/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/*.whl "artifacts/${{ needs.pre-commit.outputs.dist-base }}" # save version-specific wheels and oldest abi3 wheel python -c 'import sys if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install artifacts/${basename}/*.whl + run: pip install artifacts/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests # Reads OPENSLIDE_PATH run: pytest -v @@ -188,7 +193,7 @@ jobs: if: env.archive_wheel uses: actions/upload-artifact@v3 with: - name: ${{ env.basename }} + name: ${{ needs.pre-commit.outputs.dist-base }} path: artifacts docs: @@ -207,12 +212,9 @@ jobs: 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 with: - name: ${{ env.basename }} + name: ${{ needs.pre-commit.outputs.docs-base }} path: artifact From 6ac708e88867c1cb2332a57ddd0748e8ee9f7c3e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 11:35:44 -0600 Subject: [PATCH 035/137] workflows: automatically create GitHub release from tag Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 3 +-- .github/workflows/python.yml | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index dc26b64a..3c1c8166 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -6,8 +6,7 @@ - [ ] Find the [workflow run](https://github.com/openslide/openslide-python/actions/workflows/python.yml) for the tag; download its dist and docs artifacts - [ ] `unzip /path/to/downloaded/openslide-python-dist.zip && mv openslide-python-dist-*/* dist/` - [ ] `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 +- [ ] Verify that the workflow created a [GitHub release](https://github.com/openslide/openslide-python/releases) with release notes, a source tarball, 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` - [ ] Update Ubuntu PPA diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 04505fac..b0d7afc6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -218,3 +218,37 @@ jobs: with: name: ${{ needs.pre-commit.outputs.docs-base }} path: artifact + + release: + name: Release + if: github.ref_type == 'tag' + needs: [pre-commit, tests, windows] + runs-on: ubuntu-latest + concurrency: release-${{ github.ref }} + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: ${{ needs.pre-commit.outputs.dist-base }} + - 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 + 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 }}/"* From d8cfc7da88ede2966e074153c1fd4da5d0243d35 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 18 Nov 2023 12:35:22 -0600 Subject: [PATCH 036/137] workflows: automatically publish releases to PyPI Use PyPI's trusted publisher mechanism, which is configured to require deploying via a GitHub environment, which is configured to require manual approval. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 8 ++++---- .github/workflows/python.yml | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 3c1c8166..08ead1d9 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -2,10 +2,10 @@ - [ ] 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/workflows/python.yml) for the tag; download its dist and docs artifacts -- [ ] `unzip /path/to/downloaded/openslide-python-dist.zip && mv openslide-python-dist-*/* dist/` -- [ ] `twine upload dist/*` +- [ ] 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, a source tarball, 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` diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b0d7afc6..ec2c4d4d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -222,16 +222,24 @@ jobs: 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@v3 with: name: ${{ needs.pre-commit.outputs.dist-base }} + - name: Release to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ needs.pre-commit.outputs.dist-base }} - name: Release to GitHub env: GITHUB_TOKEN: ${{ github.token }} From 06f2ce038e228454efdb108fadfeda2abb42f809 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 21 Nov 2023 08:45:18 -0600 Subject: [PATCH 037/137] flake8: move config to pyproject.toml with Flake8-pyproject Signed-off-by: Benjamin Gilbert --- .flake8 | 6 ------ .pre-commit-config.yaml | 2 +- pyproject.toml | 8 ++++++++ 3 files changed, 9 insertions(+), 7 deletions(-) delete mode 100644 .flake8 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3cf4e80..1d048848 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: hooks: - id: flake8 name: Lint python code with flake8 - additional_dependencies: [flake8-bugbear] + additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/rstcheck/rstcheck rev: v6.2.0 diff --git a/pyproject.toml b/pyproject.toml index 699b5925..9b878cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,14 @@ version = {attr = "openslide._version.__version__"} skip-string-normalization = true target-version = ["py38", "py39", "py310", "py311", "py312"] +# 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 From b6e224c0dd4da930d3aebfe64b3c8f39998d6d11 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 12 Dec 2023 09:35:32 -0600 Subject: [PATCH 038/137] workflows: update setup-python action to v5 No breaking changes. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ec2c4d4d..3a50ca5e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -24,7 +24,7 @@ jobs: - name: Check out repo uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Run pre-commit hooks @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} if: matrix.upstream-python == null - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }} (macOS fallback) @@ -152,7 +152,7 @@ jobs: - name: Check out repo uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python tools @@ -204,7 +204,7 @@ jobs: - name: Check out repo uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install Python tools From 78b1735414c849b2ad4c330c1262ea66a8233f23 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 22 Dec 2023 07:01:53 -0600 Subject: [PATCH 039/137] workflows: get PyPI upload URL from var in GitHub environment Make it easier for forks to upload to TestPyPI. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3a50ca5e..0f3a921b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -240,6 +240,7 @@ jobs: 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 }} From fa5c3d5226a4c05cde5de329c5353600dd331304 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 22 Dec 2023 06:33:05 -0600 Subject: [PATCH 040/137] workflows: update to artifacts v4 It's no longer possible for multiple jobs to accumulate results into the same artifact. Upload a separate artifact for the sdist and each wheel, and have the release job download them all via wildcard. The sdist and wheels are already compressed, so disable compression in the wrapper Zips. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 53 ++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3a50ca5e..6aa8afbc 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -90,9 +90,11 @@ jobs: run: | case "${{ matrix.os }}" in ubuntu-latest) + echo OS_TAG=linux >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; macos-latest) + echo OS_TAG=macos >> $GITHUB_ENV brew install openslide ;; esac @@ -115,26 +117,35 @@ jobs: exit 1 fi esac - mkdir -p "artifacts/${{ needs.pre-commit.outputs.dist-base }}" - mv dist/* "artifacts/${{ needs.pre-commit.outputs.dist-base }}" + 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 }}" # save version-specific wheels and oldest abi3 wheel python -c 'import sys if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install artifacts/${{ needs.pre-commit.outputs.dist-base }}/*.whl + 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: Remove wheel from upload - if: matrix.sdist && ! env.archive_wheel - run: rm artifacts/${{ needs.pre-commit.outputs.dist-base }}/*.whl - - name: Archive dist - if: matrix.sdist || env.archive_wheel - uses: actions/upload-artifact@v3 + - 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 }} - path: artifacts + name: ${{ needs.pre-commit.outputs.dist-base }}-${{ env.OS_TAG }}-${{ matrix.python-version }} + path: artifacts/whl + compression-level: 0 windows: name: Windows @@ -170,13 +181,13 @@ jobs: - name: Build wheel run: | python -m build -w - mkdir -p "artifacts/${{ needs.pre-commit.outputs.dist-base }}" - mv dist/*.whl "artifacts/${{ needs.pre-commit.outputs.dist-base }}" + mkdir -p "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" + mv dist/*.whl "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" # save version-specific wheels and oldest abi3 wheel python -c 'import sys if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV - name: Install - run: pip install artifacts/${{ needs.pre-commit.outputs.dist-base }}/*.whl + run: pip install artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests # Reads OPENSLIDE_PATH run: pytest -v @@ -191,10 +202,11 @@ jobs: run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs - name: Archive wheel if: env.archive_wheel - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: ${{ needs.pre-commit.outputs.dist-base }} - path: artifacts + name: ${{ needs.pre-commit.outputs.dist-base }}-windows-${{ matrix.python-version }} + path: artifacts/whl + compression-level: 0 docs: name: Docs @@ -214,7 +226,7 @@ jobs: - name: Build 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: ${{ needs.pre-commit.outputs.docs-base }} path: artifact @@ -233,9 +245,10 @@ jobs: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: ${{ needs.pre-commit.outputs.dist-base }} + pattern: "${{ needs.pre-commit.outputs.dist-base }}-*" + merge-multiple: true - name: Release to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: From ab30e681d5c9ac181030c9253ce6b24bf6216d75 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 1 Jan 2024 17:03:57 -0600 Subject: [PATCH 041/137] doc: update copyright date Signed-off-by: Benjamin Gilbert --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 27ce2da2..4cdc306d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -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 From 8ba2a3b2d13a9b99007e4366738dcb46c0156e6e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 4 Feb 2024 08:54:41 +0900 Subject: [PATCH 042/137] workflows: skip installing setuptools auditwheel 6.0.0 no longer requires it. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 218c2ace..0387a07e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -85,7 +85,7 @@ jobs: - name: Install Python tools run: | python -m pip install --upgrade pip - pip install auditwheel build jinja2 pytest setuptools + pip install auditwheel build jinja2 pytest - name: Install OpenSlide run: | case "${{ matrix.os }}" in From 7af5a52d8feeb1eb77784c4e7220ae12bab7b8a3 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 28 Feb 2024 21:41:31 +0900 Subject: [PATCH 043/137] pre-commit: enable flake8 plugins in yesqa Prevent yesqa from removing noqa markers needed for plugins. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d048848..8783be38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,7 @@ repos: rev: v1.5.0 hooks: - id: yesqa + additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 From cd284c466994bdb5b894aaa968b3db2fe15731f2 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 10 Mar 2024 18:05:24 +0900 Subject: [PATCH 044/137] workflows: update pre-commit action to 3.0.1 Fix deprecation warning for Node.js 16. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 0387a07e..ddb78d56 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,7 +28,7 @@ jobs: with: python-version: '3.12' - name: Run pre-commit hooks - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 - name: Define artifact paths id: paths run: | From cf73983752e069b70b8d60c32b773a33a109d08e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 18 Mar 2024 12:06:29 +0900 Subject: [PATCH 045/137] pyproject: link to release notes from PyPI page Link to CHANGELOG.md instead of the GitHub Releases page, since it's less noisy. Signed-off-by: Benjamin Gilbert --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9b878cc7..e5f9f79b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ 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" [tool.setuptools] From 7f8af4f9caf798ba60e5d7ce7466d8143791245c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 19 Mar 2024 12:46:30 +0900 Subject: [PATCH 046/137] workflows: autodetect latest openslide-bin release rather than hardcoding a version number. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ddb78d56..4fe637b6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -151,8 +151,6 @@ jobs: name: Windows needs: pre-commit runs-on: windows-latest - env: - BIN_RELEASE: 20231011 defaults: run: shell: bash @@ -171,11 +169,17 @@ jobs: python -m pip install --upgrade pip pip install build flask pytest - name: Install OpenSlide + env: + GH_TOKEN: ${{ github.token }} run: | mkdir -p c:\\openslide cd c:\\openslide - zipname=openslide-win64-${BIN_RELEASE} - curl -LfO "https://github.com/openslide/openslide-bin/releases/download/v${BIN_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-win64-${release}" + curl -LfO "https://github.com/openslide/openslide-bin/releases/download/v${release}/${zipname}.zip" 7z x ${zipname}.zip echo "OPENSLIDE_PATH=c:\\openslide\\${zipname}\\bin" >> $GITHUB_ENV - name: Build wheel From c46c953c50c50bb594b6ae112a817586b5931bae Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 19 Mar 2024 13:09:28 +0900 Subject: [PATCH 047/137] workflows: stop hardcoding openslide-bin zip URL template Use `gh release download` instead. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4fe637b6..02562ff9 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -179,7 +179,8 @@ jobs: jq -r .[0].tagName | \ tr -d v) zipname="openslide-win64-${release}" - curl -LfO "https://github.com/openslide/openslide-bin/releases/download/v${release}/${zipname}.zip" + 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: Build wheel From 61d3518bb9b318b7781100309db0b98b2680f2eb Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 29 Mar 2024 01:56:42 +0900 Subject: [PATCH 048/137] workflows: update for new openslide-bin archive filenames Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 02562ff9..514bf57d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -178,7 +178,7 @@ jobs: --json tagName --exclude-drafts --exclude-pre-releases | \ jq -r .[0].tagName | \ tr -d v) - zipname="openslide-win64-${release}" + zipname="openslide-bin-${release}-windows-x64" gh release download -R openslide/openslide-bin "v${release}" \ --pattern "${zipname}.zip" 7z x ${zipname}.zip From 3550583c933d7eb1a20febf553a1fc2d922dcf97 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 2 May 2024 04:27:47 -0500 Subject: [PATCH 049/137] workflows: update to macOS 14 This gets us ARM64 testing and changes the Homebrew installation path. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 514bf57d..96b984b7 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-14] python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] include: - os: ubuntu-latest @@ -52,10 +52,10 @@ jobs: # setup-python's Python 3.9 and 3.10 won't build them. Use the # last upstream patch releases that ship with installers. # https://github.com/actions/setup-python/issues/439#issuecomment-1247646682 - - os: macos-latest + - os: macos-14 python-version: "3.9" upstream-python: 3.9.13 - - os: macos-latest + - os: macos-14 python-version: "3.10" upstream-python: 3.10.11 steps: @@ -93,8 +93,9 @@ jobs: echo OS_TAG=linux >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; - macos-latest) + macos-14) echo OS_TAG=macos >> $GITHUB_ENV + echo DYLD_LIBRARY_PATH=/opt/homebrew/lib >> $GITHUB_ENV brew install openslide ;; esac From 2f402a50b3597f466e6f70837cec371b8d0bb9da Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 28 Mar 2024 23:55:20 +0900 Subject: [PATCH 050/137] Add Zeiss CZI to lists of supported formats Signed-off-by: Benjamin Gilbert --- README.md | 2 ++ doc/index.rst | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index ef0f4493..a88338cc 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,6 +35,7 @@ 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/ diff --git a/doc/index.rst b/doc/index.rst index 102e6030..03bc4ea6 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/ From 7172f1d5ab893be8942931fe89264ebc8bd9d301 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 20 May 2024 16:43:22 -0500 Subject: [PATCH 051/137] workflows: switch from macos-14 to macos-latest The latter is now an alias for the former. Switch back so we get future OS updates. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 96b984b7..0a08407c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-14] + os: [ubuntu-latest, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] include: - os: ubuntu-latest @@ -52,10 +52,10 @@ jobs: # setup-python's Python 3.9 and 3.10 won't build them. Use the # last upstream patch releases that ship with installers. # https://github.com/actions/setup-python/issues/439#issuecomment-1247646682 - - os: macos-14 + - os: macos-latest python-version: "3.9" upstream-python: 3.9.13 - - os: macos-14 + - os: macos-latest python-version: "3.10" upstream-python: 3.10.11 steps: @@ -93,7 +93,7 @@ jobs: echo OS_TAG=linux >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; - macos-14) + macos-latest) echo OS_TAG=macos >> $GITHUB_ENV echo DYLD_LIBRARY_PATH=/opt/homebrew/lib >> $GITHUB_ENV brew install openslide From 42a3fdf1c74a4c394d396cf4794f5d7a23855df9 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 8 Aug 2024 03:12:30 -0600 Subject: [PATCH 052/137] pre-commit: update versions Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 14 +++++++------- setup.py | 4 +--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8783be38..9df26d73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -16,26 +16,26 @@ repos: - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.17.0 hooks: - id: pyupgrade name: Modernize python code args: ["--py38-plus"] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 24.8.0 hooks: - id: black name: Format python code with black - repo: https://github.com/asottile/blacken-docs - rev: 1.16.0 + rev: 1.18.0 hooks: - id: blacken-docs name: Format python code in documentation @@ -47,14 +47,14 @@ repos: additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.1.1 hooks: - id: flake8 name: Lint python code with flake8 additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/rstcheck/rstcheck - rev: v6.2.0 + rev: v6.2.4 hooks: - id: rstcheck name: Validate reStructuredText syntax diff --git a/setup.py b/setup.py index 07c9c702..a4702e72 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,6 @@ ], options={ # tag wheel for Limited API - 'bdist_wheel': {'py_limited_api': 'cp311'} - if _abi3 - else {}, + 'bdist_wheel': {'py_limited_api': 'cp311'} if _abi3 else {}, }, ) From bd351b1348320b2b4c587bbe52b5567c40be43cc Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 15 Aug 2024 23:22:39 -0700 Subject: [PATCH 053/137] examples/deepzoom: update jQuery to 3.7.1 Signed-off-by: Benjamin Gilbert --- examples/deepzoom/static/jquery.js | 48 +++++++++++++++++++----------- 1 file changed, 30 insertions(+), 18 deletions(-) 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 ); } } ); From a1cddc226355c1d2bf63c30d5e8084a0d2ca4d41 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 15 Aug 2024 23:24:41 -0700 Subject: [PATCH 054/137] examples/deepzoom: update OpenSeadragon to 5.0.0 Signed-off-by: Benjamin Gilbert --- examples/deepzoom/static/openseadragon.js | 6437 ++++++++++++++------- 1 file changed, 4436 insertions(+), 2001 deletions(-) diff --git a/examples/deepzoom/static/openseadragon.js b/examples/deepzoom/static/openseadragon.js index cd41170d..dec64da0 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.0 +//! Built on 2024-08-14 +//! Git commit: v5.0.0-0-f28b7fc1 //! 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.0 * @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 canvas rendering (only if the canvas 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,9 +856,9 @@ function OpenSeadragon( options ){ * @since 1.0.0 */ $.version = { - versionStr: '4.1.0', - major: parseInt('4', 10), - minor: parseInt('1', 10), + versionStr: '5.0.0', + major: parseInt('5', 10), + minor: parseInt('0', 10), revision: parseInt('0', 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 }); @@ -10170,7 +10527,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 +10544,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 +11146,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 +11677,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 +11767,6 @@ function updateOnce( viewer ) { function drawWorld( viewer ) { viewer.imageLoader.clear(); - viewer.drawer.clear(); viewer.world.draw(); /** @@ -11567,13 +11920,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 +12112,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 +12120,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 +12248,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); } } }, @@ -11929,7 +12297,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 +12308,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 +12618,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 +12740,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 +12986,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 +13354,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 +13553,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 +13760,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 +13977,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 +14356,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 +14499,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 +14972,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 +15119,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 +15409,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 +15701,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 +15741,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 +15757,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 +15894,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 +15912,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 +15968,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 +16000,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 +16043,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 +16208,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 +16581,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 +16740,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 +17304,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 +17348,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 +17495,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 +17756,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 +17914,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 +17986,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 +18190,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 +18205,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 +18250,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 +18285,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 +18337,7 @@ $.ImageJob.prototype = { /** * Starts the image job. * @method + * @memberof OpenSeadragon.ImageJob# */ start: function() { this.tries++; @@ -17979,6 +18364,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 +18518,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 +18597,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 +18796,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 +18846,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 +18918,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 @@ -18818,6 +19045,7 @@ $.Tile.prototype = { } this.element = options.element; + this.element.innerHTML = "
" + this.element.innerHTML + "
"; this.style = options.element.style; this._init(options); }; @@ -18943,19 +19171,23 @@ $.Tile.prototype = { // least one direction when this.checkResize is set to false. this.size = $.getElementSize(element); } - 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 innerElement = element.firstChild; + var innerStyle = innerElement.style; style.left = position.x + "px"; style.top = position.y + "px"; if (this.width !== null) { @@ -18969,10 +19201,20 @@ $.Tile.prototype = { 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] = ""; } @@ -19003,6 +19245,9 @@ $.Tile.prototype = { } } + if (viewport.flipped) { + position.x = (viewport.getContainerSize().x - position.x); + } return { position: position, size: size, @@ -19164,10 +19409,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 +19444,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 +19640,2753 @@ $.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(); - }, - // deprecated - numTilesLoaded: function() { - $.console.error( "[Drawer.numTilesLoaded] this function is deprecated. Use TileCache.numTilesLoaded instead." ); - return this.viewer.tileCache.numTilesLoaded(); - }, + /** + * 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 - 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 ); + let container = this.canvas; + + if (!tile.cacheImageRecord) { + $.console.warn( + '[Drawer._drawTileToHTML] attempting to draw tile %s when it\'s not cached', + tile.toString()); + return; } - }, - _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" ); + if ( !tile.loaded ) { + $.console.warn( + "Attempting to draw tile %s when it's not yet loaded.", + tile.toString() + ); + return; + } - // 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); + //EXPERIMENTAL - trying to figure out how to scale the container + // content during animation of the container size. + + if ( !tile.element ) { + var image = tile.getImage(); + if (!image) { + return; } - context = this.sketchContext; + + tile.element = $.makeNeutralElement( "div" ); + tile.imgElement = image.cloneNode(); + tile.imgElement.style.msInterpolationMode = "nearest-neighbor"; + tile.imgElement.style.width = "100%"; + tile.imgElement.style.height = "100%"; + + tile.style = tile.element.style; + tile.style.position = "absolute"; } - return context; - }, - // private - saveContext: function( useSketch ) { - if (!this.useCanvas) { - return; + if ( tile.element.parentNode !== container ) { + container.appendChild( tile.element ); + } + if ( tile.imgElement.parentNode !== tile.element ) { + tile.element.appendChild( tile.imgElement ); } - this._getContext( useSketch ).save(); - }, + 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"; - // private - restoreContext: function( useSketch ) { - if (!this.useCanvas) { - return; + if (tile.flipped) { + tile.style.transform = "scaleX(-1)"; } - this._getContext( useSketch ).restore(); - }, + $.setElementOpacity( tile.element, tile.opacity ); + } - // private - setClip: function(rect, useSketch) { - if (!this.useCanvas) { - return; - } +} - var context = this._getContext( useSketch ); - context.beginPath(); - context.rect(rect.x, rect.y, rect.width, rect.height); - context.clip(); - }, +$.HTMLDrawer = HTMLDrawer; - // private - drawRectangle: function(rect, fillStyle, useSketch) { - if (!this.useCanvas) { - return; - } - var context = this._getContext( useSketch ); - context.save(); - context.fillStyle = fillStyle; - context.fillRect(rect.x, rect.y, rect.width, rect.height); - context.restore(); - }, +}( OpenSeadragon )); - /** - * 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. +/* + * 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; + + // 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); + + let canvases = Array.from(this._TextureMap.keys()); + canvases.forEach(canvas => { + this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap + }); + + // 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); + + // 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; + let view = { + bounds: this.viewport.getBoundsNoRotate(true), + center: this.viewport.getCenter(true), + 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._firstPass.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 + /** + * Required by DrawerBase, but has no effect on WebGLDrawer. + * @param {Boolean} enabled + */ + setImageSmoothingEnabled(enabled){ + // noop - this property does not impact WebGLDrawer + } + + /** + * 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 + _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, gl.LINEAR); + 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, gl.LINEAR); + 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'); + + //make the additional canvas elements mirror size changes to the output canvas + this.viewer.addHandler("resize", 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(); + }); } - 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, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + // 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 + _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 +22394,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 +23525,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 +23557,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 +24103,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 +24115,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 +24181,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 +24225,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 +24295,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 +24308,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 +24382,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 +24410,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 +24449,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 +24462,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 +24511,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 +24542,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 +24578,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this.reset(); if (this.source.destroy) { - this.source.destroy(); + this.source.destroy(this.viewer); } }, @@ -22137,7 +24655,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 +24736,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 +24922,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 +24931,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 +24970,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 +24995,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 +25009,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ resetCroppingPolygons: function() { this._croppingPolygons = null; + this._needsDraw = true; }, /** @@ -22597,7 +25118,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 +25126,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 +25188,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 +25261,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 +25323,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 +25345,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 +25460,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 +25469,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._scaleSpring.springTo(scale); this._updateForScale(); this._needsDraw = true; + this._needsUpdate = true; } if (!sameTarget) { @@ -22898,650 +25532,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 +25910,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 +25922,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 +25943,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 +26401,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 +26442,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 +26467,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 +26493,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 +26507,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 +26514,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 +26748,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 +26805,7 @@ $.TileCache.prototype = { tile: tile, tiledImage: tiledImage }); + } }; @@ -24383,7 +26815,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 +27055,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 +27072,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; + }); }, /** From 0a7d5c5c7e188303c6ed06644c11258d8a264503 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 29 Aug 2024 02:40:21 -0700 Subject: [PATCH 055/137] workflows: add tests on Python 3.13 dev release Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 0a08407c..f567bed2 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -43,7 +43,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] include: - os: ubuntu-latest python-version: "3.12" @@ -157,7 +157,7 @@ jobs: shell: bash strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] steps: - name: Check out repo uses: actions/checkout@v4 From b9682cd0483a784a71a04bee98da2f2bed7fc2af Mon Sep 17 00:00:00 2001 From: Sukjun Kim Date: Mon, 2 Sep 2024 23:11:27 +0900 Subject: [PATCH 056/137] Remove unnecessary unpacking Signed-off-by: Sukjun Kim --- openslide/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 8834578d..61e60685 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -145,7 +145,7 @@ def get_thumbnail(self, size): """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 From ab1dbfe8486e0161730494370164fa434bab191c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 7 Sep 2024 15:12:11 -0700 Subject: [PATCH 057/137] lowlevel: single space between sentences in ModuleNotFoundErrors It reads a bit better. Signed-off-by: Benjamin Gilbert --- openslide/lowlevel.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index c80d8668..c70f0218 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -65,8 +65,8 @@ def try_load(names): return try_load(['libopenslide-1.dll', 'libopenslide-0.dll']) except FileNotFoundError: raise ModuleNotFoundError( - "Couldn't locate OpenSlide DLL. " - "Did you call os.add_dll_directory()? " + "Couldn't locate OpenSlide DLL. " + "Did you call os.add_dll_directory()? " "https://openslide.org/api/python/#installing" ) elif platform.system() == 'Darwin': @@ -81,8 +81,9 @@ 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. " + "Is OpenSlide installed correctly? " + "https://openslide.org/api/python/#installing" ) return cdll.LoadLibrary(lib) else: From 9e853e6b1b922f7dec6c7717b5f2f4a81f6f2d76 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 7 Sep 2024 15:37:53 -0700 Subject: [PATCH 058/137] Support openslide-bin Prefer loading OpenSlide from the openslide-bin package if available. For ease of use, document openslide-bin as the preferred installation option. Don't document manual installation of openslide-bin binaries on Linux and macOS, since there isn't generally an advantage to doing that, but leave the existing Windows Zip instructions for compatibility and to explain os.add_dll_directory(). Add an explanatory ModuleNotFoundError on Linux if OpenSlide is not found. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 25 +++++++++++++++++++------ README.md | 12 +++++++++--- doc/index.rst | 14 +++++++++----- openslide/lowlevel.py | 22 +++++++++++++++++++--- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f567bed2..c575a6f0 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -44,9 +44,11 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] + openslide: [system, wheel] include: - os: ubuntu-latest python-version: "3.12" + openslide: system sdist: sdist # Python 3.8 is too old to support universal binaries, and # setup-python's Python 3.9 and 3.10 won't build them. Use the @@ -86,7 +88,8 @@ jobs: run: | python -m pip install --upgrade pip pip install auditwheel build jinja2 pytest - - name: Install OpenSlide + - name: Install OpenSlide (system) + if: matrix.openslide == 'system' run: | case "${{ matrix.os }}" in ubuntu-latest) @@ -99,6 +102,9 @@ jobs: 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 @@ -124,9 +130,10 @@ jobs: fi mkdir -p "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" mv dist/* "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" - # save version-specific wheels and oldest abi3 wheel + # from system builds, save version-specific wheels and oldest abi3 wheel python -c 'import sys - if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV + if sys.version_info < (3, 12) and "${{ matrix.openslide }}" == "system": + print("archive_wheel=1")' >> $GITHUB_ENV - name: Install run: pip install artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests @@ -158,6 +165,7 @@ jobs: strategy: matrix: python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] + openslide: [zip, wheel] steps: - name: Check out repo uses: actions/checkout@v4 @@ -169,7 +177,8 @@ jobs: run: | python -m pip install --upgrade pip pip install build flask pytest - - name: Install OpenSlide + - name: Install OpenSlide (zip) + if: matrix.openslide == 'zip' env: GH_TOKEN: ${{ github.token }} run: | @@ -184,14 +193,18 @@ jobs: --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 -m build -w mkdir -p "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" mv dist/*.whl "artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}" - # save version-specific wheels and oldest abi3 wheel + # from zip builds, save version-specific wheels and oldest abi3 wheel python -c 'import sys - if sys.version_info < (3, 12): print("archive_wheel=1")' >> $GITHUB_ENV + if sys.version_info < (3, 12) and "${{ matrix.openslide }}" == "zip": + print("archive_wheel=1")' >> $GITHUB_ENV - name: Install run: pip install artifacts/whl/${{ needs.pre-commit.outputs.dist-base }}/*.whl - name: Run tests diff --git a/README.md b/README.md index a88338cc..b1ec2667 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,15 @@ OpenSlide can read virtual slides in several formats: ## 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 diff --git a/doc/index.rst b/doc/index.rst index 03bc4ea6..03799050 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -49,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. @@ -73,6 +76,7 @@ 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/ diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index c70f0218..8ab11728 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -52,6 +52,13 @@ def _load_library(): + try: + import openslide_bin + + return openslide_bin.libopenslide1 + except (AttributeError, ModuleNotFoundError): + pass + def try_load(names): for name in names: try: @@ -66,7 +73,9 @@ def try_load(names): except FileNotFoundError: raise ModuleNotFoundError( "Couldn't locate OpenSlide DLL. " - "Did you call os.add_dll_directory()? " + "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" ) elif platform.system() == 'Darwin': @@ -82,12 +91,19 @@ def try_load(names): if lib is None: raise ModuleNotFoundError( "Couldn't locate OpenSlide dylib. " - "Is OpenSlide installed correctly? " + "Try `pip install openslide-bin`. " "https://openslide.org/api/python/#installing" ) 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: + raise ModuleNotFoundError( + "Couldn't locate OpenSlide shared library. " + "Try `pip install openslide-bin`. " + "https://openslide.org/api/python/#installing" + ) _lib = _load_library() From b16b405d50f0c8d9dd5244b756737ffc926838ba Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 1 Mar 2024 17:48:32 +0900 Subject: [PATCH 059/137] pre-commit: require PEP 563 type annotations Require `from __future__ import annotations` in every Python file that isn't a known config file. Prep for adding type annotations. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 15 +++++++++++++++ doc/jekyll_fix.py | 2 ++ examples/deepzoom/deepzoom_multiserver.py | 2 ++ examples/deepzoom/deepzoom_server.py | 2 ++ examples/deepzoom/deepzoom_tile.py | 2 ++ openslide/__init__.py | 2 ++ openslide/deepzoom.py | 2 ++ openslide/lowlevel.py | 2 ++ tests/common.py | 2 ++ tests/test_base.py | 8 +++++++- tests/test_deepzoom.py | 2 ++ tests/test_imageslide.py | 2 ++ tests/test_openslide.py | 2 ++ 13 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9df26d73..5ad401b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,3 +64,18 @@ repos: 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: "(? Date: Mon, 9 Sep 2024 02:50:52 -0700 Subject: [PATCH 060/137] workflows: add CPU architecture to names of workflow artifacts We'll need to ensure uniqueness of Linux workflow artifact names once we're also building for aarch64, so make the change on all OSes for consistency. The change doesn't affect the filenames inside the artifacts, so shouldn't be user-visible. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c575a6f0..7dc90edd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -93,11 +93,11 @@ jobs: run: | case "${{ matrix.os }}" in ubuntu-latest) - echo OS_TAG=linux >> $GITHUB_ENV + echo OS_ARCH_TAG=linux-x86_64 >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; macos-latest) - echo OS_TAG=macos >> $GITHUB_ENV + echo OS_ARCH_TAG=macos-arm64-x86_64 >> $GITHUB_ENV echo DYLD_LIBRARY_PATH=/opt/homebrew/lib >> $GITHUB_ENV brew install openslide ;; @@ -151,7 +151,7 @@ jobs: if: env.archive_wheel uses: actions/upload-artifact@v4 with: - name: ${{ needs.pre-commit.outputs.dist-base }}-${{ env.OS_TAG }}-${{ matrix.python-version }} + name: ${{ needs.pre-commit.outputs.dist-base }}-${{ env.OS_ARCH_TAG }}-${{ matrix.python-version }} path: artifacts/whl compression-level: 0 @@ -223,7 +223,7 @@ jobs: if: env.archive_wheel uses: actions/upload-artifact@v4 with: - name: ${{ needs.pre-commit.outputs.dist-base }}-windows-${{ matrix.python-version }} + name: ${{ needs.pre-commit.outputs.dist-base }}-windows-x64-${{ matrix.python-version }} path: artifacts/whl compression-level: 0 From 344201313893e29e165d35268cf6a978051cf116 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 9 Sep 2024 04:33:01 -0700 Subject: [PATCH 061/137] workflows: add Linux aarch64 builds Use GitHub's paid support for ARM64 runners, pending the free offering scheduled for later this year. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7dc90edd..5074be6c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, ubuntu-24.04-aarch64, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] openslide: [system, wheel] include: @@ -96,6 +96,10 @@ jobs: echo OS_ARCH_TAG=linux-x86_64 >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; + ubuntu-24.04-aarch64) + 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 From 3d83c44b4abc8ca714a0dc220a4db25088d87584 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 7 Oct 2024 00:09:30 -0700 Subject: [PATCH 062/137] workflows: switch to Python 3.13 final Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5074be6c..307b61ab 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Run pre-commit hooks uses: pre-commit/action@v3.0.1 - name: Define artifact paths @@ -43,11 +43,11 @@ jobs: strategy: matrix: os: [ubuntu-latest, ubuntu-24.04-aarch64, macos-latest] - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] openslide: [system, wheel] include: - os: ubuntu-latest - python-version: "3.12" + python-version: "3.13" openslide: system sdist: sdist # Python 3.8 is too old to support universal binaries, and @@ -168,7 +168,7 @@ jobs: shell: bash strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] openslide: [zip, wheel] steps: - name: Check out repo @@ -241,7 +241,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install Python tools run: | python -m pip install --upgrade pip From 3e21feb01e16b92ffbfa340f7f9f134919c976e3 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 29 Sep 2024 11:27:37 -0700 Subject: [PATCH 063/137] Re-export using PEP 484 compliant syntax When importing items for the purpose of re-exporting them, use "from X import Y as Y" syntax so type checkers know that re-export is intended. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 43c9f91c..35387171 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -32,13 +32,17 @@ 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() From 013a3252f8706b7fe9f568edb71d6a818171a570 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 18:35:49 -0700 Subject: [PATCH 064/137] lowlevel: move unavailable-func handler from closure to callable object This seems slightly cleaner, especially once typechecking is added. Signed-off-by: Benjamin Gilbert --- openslide/lowlevel.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index a4edb466..dc1cbe5a 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -265,6 +265,18 @@ 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): + self._minimum_version = minimum_version + # allow checking for availability without calling the function + self.available = False + + def __call__(self, *_args): + raise OpenSlideVersionError(self._minimum_version) + + # resolve and return an OpenSlide function with the specified properties def _func(name, restype, argtypes, errcheck=_check_error, minimum_version=None): try: @@ -272,15 +284,8 @@ def _func(name, restype, argtypes, errcheck=_check_error, minimum_version=None): 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: From 0ca2cb68e66ddf869783bb21e4f82a2e0389ced9 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 11 Oct 2024 14:51:56 -0700 Subject: [PATCH 065/137] Add type-checking infrastructure Run mypy during pre-commit. For now, only type-check setup.py and _version.py. We will support type checking on Python >= 3.10. 3.9 has too many limitations to be worth supporting. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 8 ++++++++ pyproject.toml | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ad401b5..a203e535 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,14 @@ repos: name: Lint python code with flake8 additional_dependencies: [flake8-bugbear, Flake8-pyproject] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + name: Check Python types + additional_dependencies: [types-setuptools] + exclude: "^(doc/.*|openslide/(__init__|deepzoom|lowlevel)\\.py|tests/.*|examples/deepzoom/.*)$" + - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 hooks: diff --git a/pyproject.toml b/pyproject.toml index e5f9f79b..e0f67df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,12 @@ extend-ignore = ["E203", "E741"] profile = "black" force_sort_within_sections = true +[tool.mypy] +python_version = "3.10" +strict = true +# temporary, while we bootstrap type checking +follow_imports = "silent" + [tool.pytest.ini_options] minversion = "7.0" # don't try to import openslide from the source directory, since it doesn't From a40175e028b546a8e84fb19467630c7d3656e8d1 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 29 Sep 2024 11:26:58 -0700 Subject: [PATCH 066/137] _convert: add type hints Because of the underscore, _convert is (correctly) not part of the external API, so this is for internal typechecking purposes only. The .pyi file will still be installed though. Signed-off-by: Benjamin Gilbert --- openslide/_convert.pyi | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 openslide/_convert.pyi diff --git a/openslide/_convert.pyi b/openslide/_convert.pyi new file mode 100644 index 00000000..c68c9781 --- /dev/null +++ b/openslide/_convert.pyi @@ -0,0 +1,26 @@ +# +# 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, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +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: ... From 32656fec38a676bb8828cdcbd53f2712606b5612 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 17:51:10 -0700 Subject: [PATCH 067/137] lowlevel: add type hints Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 4 +- openslide/lowlevel.py | 187 +++++++++++++++++++++++++++------------- 2 files changed, 129 insertions(+), 62 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a203e535..76328b74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,8 +58,8 @@ repos: hooks: - id: mypy name: Check Python types - additional_dependencies: [types-setuptools] - exclude: "^(doc/.*|openslide/(__init__|deepzoom|lowlevel)\\.py|tests/.*|examples/deepzoom/.*)$" + additional_dependencies: [openslide-bin, pillow, types-setuptools] + exclude: "^(doc/.*|openslide/(__init__|deepzoom)\\.py|tests/.*|examples/deepzoom/.*)$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index dc1cbe5a..2042a9a5 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -33,7 +33,9 @@ from __future__ import annotations from ctypes import ( + CDLL, POINTER, + _Pointer, byref, c_char, c_char_p, @@ -47,13 +49,20 @@ ) from itertools import count 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+ for ParamSpec + from typing import ParamSpec, TypeAlias -def _load_library(): + from _convert import _Buffer + + +def _load_library() -> CDLL: try: import openslide_bin @@ -61,13 +70,15 @@ def _load_library(): except (AttributeError, ModuleNotFoundError): pass - def try_load(names): + def try_load(names: list[str]) -> CDLL: for name in names: try: return cdll.LoadLibrary(name) except OSError: if name == names[-1]: raise + else: + raise ValueError('No library names specified') if platform.system() == 'Windows': try: @@ -124,7 +135,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 @@ -139,22 +150,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_: @@ -167,17 +178,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_: @@ -189,7 +200,7 @@ 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): @@ -202,7 +213,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: @@ -210,14 +221,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)) @@ -228,17 +239,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: @@ -246,7 +257,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) @@ -254,7 +266,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(): @@ -268,19 +280,45 @@ def _check_name_list(result, func, args): class _FunctionUnavailable: '''Standin for a missing optional function. Fails when called.''' - def __init__(self, minimum_version): + 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): + 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: ... + + 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 @@ -294,8 +332,15 @@ def _func(name, restype, argtypes, errcheck=_check_error, minimum_version=None): 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 @@ -305,17 +350,27 @@ def decorator(f): try: - detect_vendor = _func('openslide_detect_vendor', c_char_p, [_utf8_p], _check_string) + detect_vendor: _Func[[str], str] = _func( + 'openslide_detect_vendor', c_char_p, [_utf8_p], _check_string + ) except AttributeError: raise OpenSlideVersionError('3.4.0') -open = _func('openslide_open', c_void_p, [_utf8_p], _check_open) +open: _Func[[str], _OpenSlide] = _func( + 'openslide_open', c_void_p, [_utf8_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)], @@ -323,29 +378,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 @@ -360,14 +419,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)], @@ -376,7 +435,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 @@ -385,24 +444,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], 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, _Pointer[c_int64], _Pointer[c_int64]], None +] = _func( 'openslide_get_associated_image_dimensions', None, [_OpenSlide, _utf8_p, POINTER(c_int64), POINTER(c_int64)], @@ -410,44 +473,46 @@ 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) -> 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( +_read_associated_image: _Func[[_OpenSlide, str, _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) -> 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], 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( - 'openslide_read_associated_image_icc_profile', - None, - [_OpenSlide, _utf8_p, POINTER(c_char)], - minimum_version='4.0.0', +_read_associated_image_icc_profile: _Func[[_OpenSlide, str, _Pointer[c_char]], None] = ( + _func( + 'openslide_read_associated_image_icc_profile', + None, + [_OpenSlide, _utf8_p, POINTER(c_char)], + minimum_version='4.0.0', + ) ) @_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 | None: size = get_associated_image_icc_profile_size(slide, name) if size == 0: return None @@ -456,9 +521,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], @@ -466,7 +533,7 @@ 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], @@ -474,6 +541,6 @@ def read_associated_image_icc_profile(slide, name): 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' ) From f31c8b2d835abd4914fee8945fd9319e4ac53a97 Mon Sep 17 00:00:00 2001 From: Sam Maxwell Date: Wed, 21 Feb 2024 16:54:56 +0000 Subject: [PATCH 068/137] add typing for main interface file Signed-off-by: Sam Maxwell Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 148 +++++++++++++++++++++++++----------------- 1 file changed, 88 insertions(+), 60 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 35387171..38198e80 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -25,8 +25,10 @@ from __future__ import annotations -from collections.abc import Mapping from io import BytesIO +from pathlib import Path +from types import TracebackType +from typing import Iterator, Literal, Mapping, Protocol, TypeVar from PIL import Image, ImageCms @@ -59,80 +61,91 @@ PROPERTY_NAME_BOUNDS_HEIGHT = 'openslide.bounds-height' +class _OpenSlideCacheWrapper(Protocol): + _openslide_cache: lowlevel._OpenSlideCache + + class AbstractSlide: """The base class of a slide object.""" - def __init__(self): + def __init__(self) -> None: self._profile = None - def __enter__(self): + def __enter__(self) -> AbstractSlide: 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): + def detect_format(cls, filename: Path | str) -> 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): + def close(self) -> None: """Close the slide.""" raise NotImplementedError @property - def level_count(self): + def level_count(self) -> int: """The number of levels in the image.""" raise NotImplementedError @property - def level_dimensions(self): + def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A list 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): + def level_downsamples(self) -> tuple[float, ...]: """A list 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): + 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): + 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): + 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): + 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 @@ -141,13 +154,13 @@ 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: _OpenSlideCacheWrapper) -> None: """Use the specified cache to store recently decoded slide tiles. cache: an OpenSlideCache object.""" raise NotImplementedError - 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.""" @@ -178,7 +191,7 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename): + def __init__(self, filename: Path | str | bytes) -> None: """Open a whole-slide image.""" AbstractSlide.__init__(self) self._filename = filename @@ -186,27 +199,27 @@ def __init__(self, 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: Path | str) -> 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)) - 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): + def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A list of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" @@ -215,7 +228,7 @@ def level_dimensions(self): ) @property - def level_downsamples(self): + def level_downsamples(self) -> tuple[float, ...]: """A list of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" @@ -224,14 +237,14 @@ def level_downsamples(self): ) @property - def properties(self): + def properties(self) -> _OpenSlideMap[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) -> _OpenSlideMap[Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image. @@ -240,11 +253,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 @@ -261,7 +276,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: _OpenSlideCacheWrapper) -> None: """Use the specified cache to store recently decoded slide tiles. By default, the object has a private cache with a default size. @@ -274,44 +289,47 @@ def set_cache(self, cache): lowlevel.set_cache(self._osr, llcache) -class _OpenSlideMap(Mapping): - def __init__(self, osr): +MapValue = TypeVar('MapValue', str, Image.Image) + + +class _OpenSlideMap(Mapping[str, MapValue]): + 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): + 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) @@ -333,19 +351,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: str | bytes | Path | Image.Image): """Open an image file. file can be a filename or a PIL.Image.""" @@ -359,11 +377,11 @@ def __init__(self, file): 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: str | bytes | Path) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" @@ -373,51 +391,54 @@ def detect_format(cls, filename): except OSError: return None - def close(self): + def close(self) -> None: """Close the slide object.""" if self._close: self._image.close() self._close = False - self._image = None + # is it necessary to set to None? + self._image = None # type: ignore @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): + def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A list of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" return (self._image.size,) @property - def level_downsamples(self): + def level_downsamples(self) -> tuple[float, ...]: """A list 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) -> dict[str, str]: """Metadata about the image. This is a map: property name -> property value.""" return {} @property - def associated_images(self): + def associated_images(self) -> dict[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 @@ -444,14 +465,21 @@ 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]) - tile_offset = tuple(il - l for il, l in zip(image_topleft, location)) + crop = self._image.crop( + ( + image_topleft[0], + image_topleft[1], + image_bottomright[0] + 1, + image_bottomright[1] + 1, + ) + ) + tile_offset = image_topleft[0] - location[0], image_topleft[1] - location[1] tile.paste(crop, tile_offset) if self._profile is not None: tile.info['icc_profile'] = self._profile return tile - def set_cache(self, cache): + def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: """Use the specified cache to store recently decoded slide tiles. ImageSlide does not support caching, so this method does nothing. @@ -460,7 +488,7 @@ def set_cache(self, cache): pass -def open_slide(filename): +def open_slide(filename: str | bytes | Path) -> AbstractSlide: """Open a whole-slide or regular image. Return an OpenSlide object for whole-slide images and an ImageSlide From f4b813141ab23fc9aa17d3cb05df7eca97517117 Mon Sep 17 00:00:00 2001 From: Sam Maxwell Date: Thu, 22 Feb 2024 00:15:48 +0000 Subject: [PATCH 069/137] type the deepzoom generator Signed-off-by: Sam Maxwell --- openslide/deepzoom.py | 93 ++++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 66734d70..df802559 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -46,7 +46,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. @@ -101,7 +107,7 @@ def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False): self._z_dimensions = 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( @@ -112,7 +118,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) ) @@ -134,7 +141,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, @@ -144,26 +151,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): + def level_tiles(self) -> tuple[tuple[int, int], ...]: """A list of (tiles_x, tiles_y) tuples for each Deep Zoom level.""" return self._t_dimensions @property - def level_dimensions(self): + def level_dimensions(self) -> tuple[tuple[int, ...], ...]: """A list 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. @@ -191,7 +198,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") @@ -210,42 +219,62 @@ def _get_tile_info(self, dz_level, t_location): ) # Get final size of the tile - z_size = tuple( - min(self._z_t_downsample, z_lim - self._z_t_downsample * t) + z_tl + z_br - for t, z_lim, z_tl, z_br in zip( - t_location, self._z_dimensions[dz_level], z_overlap_tl, z_overlap_br + z_size = ( + min( + self._z_t_downsample, + self._z_dimensions[dz_level][0] - self._z_t_downsample * t_location[0], ) + + z_overlap_tl[0] + + z_overlap_br[0], + min( + self._z_t_downsample, + self._z_dimensions[dz_level][1] - self._z_t_downsample * t_location[1], + ) + + z_overlap_tl[1] + + z_overlap_br[1], ) # Obtain the region coordinates - z_location = [self._z_from_t(t) for t in t_location] - l_location = [ - self._l_from_z(dz_level, z - z_tl) - for z, z_tl in zip(z_location, z_overlap_tl) - ] + z_location = (self._z_from_t(t_location[0]), self._z_from_t(t_location[1])) + l_location = ( + self._l_from_z(dz_level, z_location[0] - z_overlap_tl[0]), + self._l_from_z(dz_level, z_location[1] - z_overlap_tl[1]), + ) # Round location down and size up, and add offset of active area - l0_location = tuple( - int(self._l0_from_l(slide_level, l) + l0_off) - for l, l0_off in zip(l_location, self._l0_offset) + l0_location = ( + int(self._l0_from_l(slide_level, l_location[0]) + self._l0_offset[0]), + int(self._l0_from_l(slide_level, l_location[1]) + self._l0_offset[1]), ) - l_size = tuple( - int(min(math.ceil(self._l_from_z(dz_level, dz)), l_lim - math.ceil(l))) - for l, dz, l_lim in zip(l_location, z_size, self._l_dimensions[slide_level]) + l_size = ( + int( + min( + math.ceil(self._l_from_z(dz_level, z_size[0])), + self._l_dimensions[slide_level][0] - math.ceil(l_location[0]), + ) + ), + int( + min( + math.ceil(self._l_from_z(dz_level, z_size[1])), + self._l_dimensions[slide_level][1] - math.ceil(l_location[1]), + ) + ), ) # Return read_region() parameters plus tile size for final scaling 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): + 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 @@ -256,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. @@ -264,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')""" From 5032e2352e3361467faa07ab80f6c881f5b782aa Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 16 Oct 2024 08:40:46 -0700 Subject: [PATCH 070/137] Minor type fixes Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 38198e80..7f8af31b 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -69,7 +69,7 @@ class AbstractSlide: """The base class of a slide object.""" def __init__(self) -> None: - self._profile = None + self._profile: bytes | None = None def __enter__(self) -> AbstractSlide: return self @@ -387,7 +387,9 @@ def detect_format(cls, filename: str | bytes | Path) -> str | None: 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 From a6c2f820eabbdeeb55117778891bd2010210acc6 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 18:26:31 -0700 Subject: [PATCH 071/137] Enable type checking for entire openslide package Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 76328b74..6041918a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,7 @@ repos: - id: mypy name: Check Python types additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|openslide/(__init__|deepzoom)\\.py|tests/.*|examples/deepzoom/.*)$" + exclude: "^(doc/.*|tests/.*|examples/deepzoom/.*)$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/pyproject.toml b/pyproject.toml index e0f67df2..8fd7be02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,6 @@ force_sort_within_sections = true [tool.mypy] python_version = "3.10" strict = true -# temporary, while we bootstrap type checking -follow_imports = "silent" [tool.pytest.ini_options] minversion = "7.0" From e52cb3a5c5057f1fb984948d13918431da855acd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 23:15:54 -0700 Subject: [PATCH 072/137] Return subclass from AbstractSlide.__enter__() AbstractSlide subclasses will always return their specific type from this method. Ensure the type system knows this. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 7f8af31b..521c0f18 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -60,6 +60,8 @@ PROPERTY_NAME_BOUNDS_WIDTH = 'openslide.bounds-width' PROPERTY_NAME_BOUNDS_HEIGHT = 'openslide.bounds-height' +_T = TypeVar('_T') + class _OpenSlideCacheWrapper(Protocol): _openslide_cache: lowlevel._OpenSlideCache @@ -71,7 +73,7 @@ class AbstractSlide: def __init__(self) -> None: self._profile: bytes | None = None - def __enter__(self) -> AbstractSlide: + def __enter__(self: _T) -> _T: return self def __exit__( From 87495baabd5fd40b0eccdf3203ace4417ace6266 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 23:03:55 -0700 Subject: [PATCH 073/137] Disallow bytes in filename arguments bytes will not work in at least one case (the OpenSlide initializer), are not documented to work, and the type annotations are currently inconsistent about accepting it. For now, forbid it everywhere for consistency. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 521c0f18..a6ebe380 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -86,7 +86,7 @@ def __exit__( return False @classmethod - def detect_format(cls, filename: Path | str) -> str | None: + def detect_format(cls, filename: str | Path) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" @@ -193,7 +193,7 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename: Path | str | bytes) -> None: + def __init__(self, filename: str | Path) -> None: """Open a whole-slide image.""" AbstractSlide.__init__(self) self._filename = filename @@ -205,7 +205,7 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._filename!r})' @classmethod - def detect_format(cls, filename: Path | str) -> str | None: + def detect_format(cls, filename: str | Path) -> str | None: """Return a string describing the format vendor of the specified file. If the file format is not recognized, return None.""" @@ -365,7 +365,7 @@ def __repr__(self) -> str: class ImageSlide(AbstractSlide): """A wrapper for a PIL.Image that provides the OpenSlide interface.""" - def __init__(self, file: str | bytes | Path | Image.Image): + def __init__(self, file: str | Path | Image.Image): """Open an image file. file can be a filename or a PIL.Image.""" @@ -383,7 +383,7 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._file_arg!r})' @classmethod - def detect_format(cls, filename: str | bytes | Path) -> str | None: + def detect_format(cls, filename: str | Path) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" @@ -492,7 +492,7 @@ def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: pass -def open_slide(filename: str | bytes | Path) -> AbstractSlide: +def open_slide(filename: str | Path) -> AbstractSlide: """Open a whole-slide or regular image. Return an OpenSlide object for whole-slide images and an ImageSlide From 8c2636185927b3e0f51d9791576f45b579a2b9c5 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 01:33:50 -0700 Subject: [PATCH 074/137] Explicitly hint the possible open_slide() return types open_slide() can't return any possible AbstractSlide subclass, only the two that it's documented to return. Make that explicit in the type hint. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index a6ebe380..a9759f3e 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -492,7 +492,7 @@ def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: pass -def open_slide(filename: str | Path) -> AbstractSlide: +def open_slide(filename: str | Path) -> OpenSlide | ImageSlide: """Open a whole-slide or regular image. Return an OpenSlide object for whole-slide images and an ImageSlide From 076e96fdfcbebea30ffeddd7b25d84bcca8d5aff Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 23:08:32 -0700 Subject: [PATCH 075/137] Loosen mapping type guarantees Avoid overcommitting to internal implementation details in the types being returned. It's sufficient to say that the property and associated image maps implement Mapping. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index a9759f3e..a1056cd1 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -239,14 +239,14 @@ def level_downsamples(self) -> tuple[float, ...]: ) @property - def properties(self) -> _OpenSlideMap[str]: + 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) -> _OpenSlideMap[Image.Image]: + def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image. @@ -291,10 +291,7 @@ def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: lowlevel.set_cache(self._osr, llcache) -MapValue = TypeVar('MapValue', str, Image.Image) - - -class _OpenSlideMap(Mapping[str, MapValue]): +class _OpenSlideMap(Mapping[str, _T]): def __init__(self, osr: lowlevel._OpenSlide): self._osr = osr @@ -423,14 +420,14 @@ def level_downsamples(self) -> tuple[float, ...]: return (1.0,) @property - def properties(self) -> dict[str, str]: + def properties(self) -> Mapping[str, str]: """Metadata about the image. This is a map: property name -> property value.""" return {} @property - def associated_images(self) -> dict[str, Image.Image]: + def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. This is a map: image name -> PIL.Image.""" From dccce62198666eef48181ec221022a1e5524a8bd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 22:38:05 -0700 Subject: [PATCH 076/137] Drop _OpenSlideCacheWrapper We don't want to encourage library users to duck-type a replacement for OpenSlideCache, since any replacement would inherently need to make assumptions about our implementation details. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index a1056cd1..be3a9871 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -28,7 +28,7 @@ from io import BytesIO from pathlib import Path from types import TracebackType -from typing import Iterator, Literal, Mapping, Protocol, TypeVar +from typing import Iterator, Literal, Mapping, TypeVar from PIL import Image, ImageCms @@ -63,10 +63,6 @@ _T = TypeVar('_T') -class _OpenSlideCacheWrapper(Protocol): - _openslide_cache: lowlevel._OpenSlideCache - - class AbstractSlide: """The base class of a slide object.""" @@ -156,7 +152,7 @@ def read_region( size: (width, height) tuple giving the region size.""" raise NotImplementedError - def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: + def set_cache(self, cache: OpenSlideCache) -> None: """Use the specified cache to store recently decoded slide tiles. cache: an OpenSlideCache object.""" @@ -278,7 +274,7 @@ def read_region( region.info['icc_profile'] = self._profile return region - def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: + 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. @@ -480,7 +476,7 @@ def read_region( tile.info['icc_profile'] = self._profile return tile - def set_cache(self, cache: _OpenSlideCacheWrapper) -> None: + def set_cache(self, cache: OpenSlideCache) -> None: """Use the specified cache to store recently decoded slide tiles. ImageSlide does not support caching, so this method does nothing. From cfd166361b37d2af89e92e35eac3755a626f72c4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 17 Oct 2024 23:10:56 -0700 Subject: [PATCH 077/137] Explicitly check for closed ImageSlide When an operation is performed on a closed ImageSlide, we've historically raised an AttributeError upon dereferencing None. Add proper checks so the type system understands what we're doing, raising ValueError as lowlevel does. This is not an API change because it only affects invalid caller behavior. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 10 +++++++--- tests/test_imageslide.py | 8 ++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index be3a9871..67960d88 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -366,7 +366,7 @@ def __init__(self, file: str | Path | Image.Image): 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) @@ -391,10 +391,10 @@ def detect_format(cls, filename: str | Path) -> str | None: def close(self) -> None: """Close the slide object.""" if self._close: + assert self._image is not None self._image.close() self._close = False - # is it necessary to set to None? - self._image = None # type: ignore + self._image = None @property def level_count(self) -> Literal[1]: @@ -406,6 +406,8 @@ def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A list 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('Passing closed slide object') return (self._image.size,) @property @@ -442,6 +444,8 @@ def read_region( reference frame. level: the level number. size: (width, height) tuple giving the region size.""" + if self._image is None: + raise ValueError('Passing closed slide object') if level != 0: raise OpenSlideError("Invalid level") if ['fail' for s in size if s < 0]: diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index 603cb72b..dd00ad66 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -49,8 +49,9 @@ def test_operations_on_closed_handle(self): 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) @@ -59,9 +60,8 @@ def test_context_manager(self): 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 _SlideTest: From f24c1dd5bf72af8caa284205d2de7735c1b12462 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 23:43:02 -0700 Subject: [PATCH 078/137] deepzoom: revert unrolling of _get_tile_info() We don't need to avoid generator expressions to keep the type checker happy; we can just assert that the resulting tuples have the correct length. This lets us avoid carrying redundant unrolled code. Signed-off-by: Benjamin Gilbert --- openslide/deepzoom.py | 49 ++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index df802559..c0d1a4af 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -219,48 +219,31 @@ def _get_tile_info( ) # Get final size of the tile - z_size = ( - min( - self._z_t_downsample, - self._z_dimensions[dz_level][0] - self._z_t_downsample * t_location[0], + z_size = tuple( + min(self._z_t_downsample, z_lim - self._z_t_downsample * t) + z_tl + z_br + for t, z_lim, z_tl, z_br in zip( + t_location, self._z_dimensions[dz_level], z_overlap_tl, z_overlap_br ) - + z_overlap_tl[0] - + z_overlap_br[0], - min( - self._z_t_downsample, - self._z_dimensions[dz_level][1] - self._z_t_downsample * t_location[1], - ) - + z_overlap_tl[1] - + z_overlap_br[1], ) # Obtain the region coordinates - z_location = (self._z_from_t(t_location[0]), self._z_from_t(t_location[1])) - l_location = ( - self._l_from_z(dz_level, z_location[0] - z_overlap_tl[0]), - self._l_from_z(dz_level, z_location[1] - z_overlap_tl[1]), - ) + z_location = [self._z_from_t(t) for t in t_location] + l_location = [ + self._l_from_z(dz_level, z - z_tl) + for z, z_tl in zip(z_location, z_overlap_tl) + ] # Round location down and size up, and add offset of active area - l0_location = ( - int(self._l0_from_l(slide_level, l_location[0]) + self._l0_offset[0]), - int(self._l0_from_l(slide_level, l_location[1]) + self._l0_offset[1]), + l0_location = tuple( + int(self._l0_from_l(slide_level, l) + l0_off) + for l, l0_off in zip(l_location, self._l0_offset) ) - l_size = ( - int( - min( - math.ceil(self._l_from_z(dz_level, z_size[0])), - self._l_dimensions[slide_level][0] - math.ceil(l_location[0]), - ) - ), - int( - min( - math.ceil(self._l_from_z(dz_level, z_size[1])), - self._l_dimensions[slide_level][1] - math.ceil(l_location[1]), - ) - ), + l_size = tuple( + int(min(math.ceil(self._l_from_z(dz_level, dz)), l_lim - math.ceil(l))) + for l, dz, l_lim in zip(l_location, z_size, self._l_dimensions[slide_level]) ) # 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: int, l: float) -> float: From 0d675d65c11a28e86f1f3e8dfd80a749626cb14f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 19 Oct 2024 00:29:41 -0700 Subject: [PATCH 079/137] deepzoom: fix level_dimensions type hint level_dimensions is always a tuple of 2-tuples. Prove this to the type checker. Signed-off-by: Benjamin Gilbert --- openslide/deepzoom.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index c0d1a4af..623e3adb 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -27,12 +27,17 @@ 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.""" @@ -104,7 +109,8 @@ def __init__( 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: int) -> int: @@ -161,7 +167,7 @@ def level_tiles(self) -> tuple[tuple[int, int], ...]: return self._t_dimensions @property - def level_dimensions(self) -> tuple[tuple[int, ...], ...]: + def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A list of (pixels_x, pixels_y) tuples for each Deep Zoom level.""" return self._z_dimensions @@ -255,6 +261,18 @@ def _l_from_z(self, dz_level: int, z: int) -> float: def _z_from_t(self, t: int) -> int: return self._z_t_downsample * t + @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]]: From 981684ba6abdce19cdd41a4f8e2c9b61e7f32aa7 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 19 Oct 2024 05:13:33 -0700 Subject: [PATCH 080/137] Revert unrolling of ImageSlide.read_region() Keep the type checker happy by asserting the correct tuple lengths rather than unrolling generator expressions. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 67960d88..dc85a630 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -466,15 +466,10 @@ def read_region( ]: # "< 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[0], - image_topleft[1], - image_bottomright[0] + 1, - image_bottomright[1] + 1, - ) - ) - tile_offset = image_topleft[0] - location[0], image_topleft[1] - location[1] + 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 From f1d02fc24f1e942fa85faf23c29f6d14c1d60e4b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 19 Oct 2024 06:40:03 -0700 Subject: [PATCH 081/137] Drop redundant return type hint __init__() methods don't need a return type hint unless they take no arguments. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index dc85a630..e6999d52 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -189,7 +189,7 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename: str | Path) -> None: + def __init__(self, filename: str | Path): """Open a whole-slide image.""" AbstractSlide.__init__(self) self._filename = filename From 0efc25bb440e3cf782a131f3d9ef6875f8df4967 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 19 Oct 2024 06:42:57 -0700 Subject: [PATCH 082/137] Narrow some ImageSlide property types ImageSlide is hardcoded to a single level, and the level_count property and get_best_level_for_downsample() return types already reflect this. Reduce the level_dimensions() and level_downsamples() return types to 1-tuples. In the latter case we can't reduce to a Literal because literal floats are disallowed. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index e6999d52..53c70756 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -402,7 +402,7 @@ def level_count(self) -> Literal[1]: return 1 @property - def level_dimensions(self) -> tuple[tuple[int, int], ...]: + def level_dimensions(self) -> tuple[tuple[int, int]]: """A list of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" @@ -411,7 +411,7 @@ def level_dimensions(self) -> tuple[tuple[int, int], ...]: return (self._image.size,) @property - def level_downsamples(self) -> tuple[float, ...]: + def level_downsamples(self) -> tuple[float]: """A list of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" From 3d16e588cf9be9e1bec510d34af9fd5528ae1f59 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 17 Oct 2024 22:56:02 -0700 Subject: [PATCH 083/137] pre-commit: update versions Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6041918a..dca2fb10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.17.0 + rev: v3.18.0 hooks: - id: pyupgrade name: Modernize python code @@ -29,13 +29,13 @@ repos: name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 24.8.0 + rev: 24.10.0 hooks: - id: black name: Format python code with black - repo: https://github.com/asottile/blacken-docs - rev: 1.18.0 + rev: 1.19.0 hooks: - id: blacken-docs name: Format python code in documentation @@ -54,7 +54,7 @@ repos: additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.12.0 hooks: - id: mypy name: Check Python types From 8f35f0a53e8eb54f07b36e336652bd9cb89faead Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 02:23:34 -0700 Subject: [PATCH 084/137] tests: drop obsolete Pillow version test Pillow < 6.2.0 doesn't support any version of Python we still support. Signed-off-by: Benjamin Gilbert --- tests/test_openslide.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 7ee86363..ab134f33 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -24,7 +24,6 @@ import sys import unittest -from PIL import Image from common import file_path from openslide import ( @@ -154,12 +153,6 @@ def test_read_region_bad_size(self): ) @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): From a65540ee6d022ccc7f851e2b7e4df89188c61cdd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 00:59:41 -0700 Subject: [PATCH 085/137] Fix several problems with filename handling - Starting in 1.2.0, OpenSlide() and OpenSlide.detect_format() have failed to accept filename arguments formatted as bytes because str(b'abc') == "b'abc'". In addition, filename arguments with invalid types (such as None) have been stringified and passed to OpenSlide, rather than raising an exception during conversion; we even had tests for this (!). - lowlevel has always encoded filename arguments to UTF-8, but on non-Windows it should have used the Python filesystem encoding instead (usually UTF-8 but not always). On Windows, OpenSlide 4.0.0+ expects UTF-8 rather than arbitrary bytes. (OpenSlide < 4.0.0 expects the system codepage, which isn't very useful in practice because of its limited character set, so we ignore that case for now.) - Type hints did not allow filename arguments to be bytes, nor did they allow os.PathLike subclasses which were not pathlib.Path (such as pathlib.PurePath). Accept str, bytes, or os.PathLike for all filename arguments, and properly convert them to bytes for OpenSlide. Fixes: 98c11bd4ca2b ("Add support for pathlib.Path instances (#123)") Fixes: 564422975d9b ("tests: test passing invalid types to OpenSlide constructor") Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 17 ++++++------- openslide/lowlevel.py | 35 +++++++++++++++++++++----- "tests/fixtures/\360\237\230\220.png" | Bin 0 -> 589 bytes "tests/fixtures/\360\237\230\220.svs" | Bin 0 -> 2651 bytes tests/test_base.py | 2 +- tests/test_imageslide.py | 18 ++++++++++++- tests/test_openslide.py | 21 +++++++++++++--- 7 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 "tests/fixtures/\360\237\230\220.png" create mode 100644 "tests/fixtures/\360\237\230\220.svs" diff --git a/openslide/__init__.py b/openslide/__init__.py index 53c70756..65022b41 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -26,7 +26,6 @@ from __future__ import annotations from io import BytesIO -from pathlib import Path from types import TracebackType from typing import Iterator, Literal, Mapping, TypeVar @@ -82,7 +81,7 @@ def __exit__( return False @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + 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.""" @@ -189,11 +188,11 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename: str | Path): + 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) @@ -201,11 +200,11 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._filename!r})' @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + 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) -> None: """Close the OpenSlide object.""" @@ -358,7 +357,7 @@ def __repr__(self) -> str: class ImageSlide(AbstractSlide): """A wrapper for a PIL.Image that provides the OpenSlide interface.""" - def __init__(self, file: str | Path | Image.Image): + def __init__(self, file: lowlevel.Filename | Image.Image): """Open an image file. file can be a filename or a PIL.Image.""" @@ -376,7 +375,7 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._file_arg!r})' @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + 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.""" @@ -484,7 +483,7 @@ def set_cache(self, cache: OpenSlideCache) -> None: pass -def open_slide(filename: str | Path) -> OpenSlide | ImageSlide: +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/lowlevel.py b/openslide/lowlevel.py index 2042a9a5..5efa5fa9 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 @@ -48,6 +48,7 @@ cdll, ) from itertools import count +import os import platform from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, cast @@ -56,7 +57,7 @@ from . import _convert if TYPE_CHECKING: - # Python 3.10+ for ParamSpec + # Python 3.10+ from typing import ParamSpec, TypeAlias from _convert import _Buffer @@ -196,6 +197,28 @@ def from_param(cls, obj: _OpenSlideCache) -> _OpenSlideCache: 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.""" @@ -350,14 +373,14 @@ def decorator(fn: Callable[_P, _T]) -> _Func[_P, _T]: try: - detect_vendor: _Func[[str], str] = _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[[str], _OpenSlide] = _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], None] = _func( diff --git "a/tests/fixtures/\360\237\230\220.png" "b/tests/fixtures/\360\237\230\220.png" new file mode 100644 index 0000000000000000000000000000000000000000..fccc4fe5095c5fb963f5328980dbff091e754bfc GIT binary patch literal 589 zcmeAS@N?(olHy`uVBq!ia0y~yVAKJ!e=#uw$u#Ck2|$6;OlRi+PiJR^fTH}g%$!sP zhKf0*6K%Z@2Z*%EuU@)HlwHTZ6Z zu-Fn-R-y0NLe}5aJt^JHlu_}j@wAl*Yn#5VnJoTJ_}#hZ zT)*zyyz*YuW@@gyHMmS%PgBiPz{_xMLHJQ}~8(C%h%Wip2Us82b@p8*{9atuA2Vd{gDIjyCb{0XT-iE5@N{Fprru`A<3Z;(R9+&9+yXp#6L2ivk?9M?~zM3L*!i}H@ zBmVzQ1_6eD3@kt&LjfZ*m@NS0G9s~=fNWsEFx&$wWQK~f1Nm%FHZZ&x_!yZOGJuRH zKsBOJagZJvC>vyl6O`=$WIH3VT^J#50NLvbWwQe{L^HB8C<19876t|(CT38)05KE8 z(*}rokeOy|3=AA#=va_il$o#KnVXoNs^F8ERFqg$sZeHUqz8lwyj&(`1_lNd#zvNg zCJNDpmKGL07nQ;UG|NhXFS1_s6{x|XKNX1XR8Nd~%>#%7kf76yg} zsVSz0=1Ga>H35l5xy7j^K;@yqz6wCAN>Ynzd;%qpBv939)bwQa(@#jDq@PH)(A za@VrsGgqu$^8W~fJTRNFGJ*jxC4&JY6Eh1d8#@Ol7dKGBRsjZJ-eYEBVP<7z0cHZm zTA(}wiy*6zqM;+3a9|?4QlW@Z(iUwW$pkka<)WpdpCN3cY31zV>gMj@=@lFj8WtWA8I_!pnwFlCnN?g;T2@|BS=HRq+ScCD*)?hMl&RCE z&zL!D(c&dbmn~nha@D5ITefc7zGLUELx+zXJ$C%W$y1juU%7hi`i+~n9zJ^f}jBP9rNV4wdIB1yvyfKnd+if z_=3&1>gbhcx9&xHuqfQ^53stdlOG(gweOi>qgsXS#=uOgrmR^x<`a7=o0l(SpOxBr z>fWt2uMenC4Bq1PrnOViQR0o`*Okv2&Tm(L!WFVUjeT8KcyG#qItLcPSG-2I&gSwo z8E%z%qW`^as_fJ`Wx56}f-?Pk!#7PbD0~}u>-O61GdeijERQe0wEzE2ev(SZ55STL zU+L%!EWW-0)d)gMxLj7$l2Qy>BCY}^1aYXElR!3d2?{F_tvGR)h@f;tM2W}+F8364 z?H~o9g08}7VK-XX<)9UIu+ol;ot=Z7jf;bWgPV(sn@>W3kC&HER#Z$-LRnr_MM+*s mQBB)iUrp0QQ&Gvl(a^-w+Rn~SRnNuK*~Y`%*3NqPmUaN}3g}V* literal 0 HcmV?d00001 diff --git a/tests/test_base.py b/tests/test_base.py index bcced6f8..d03ce7cf 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -45,7 +45,7 @@ def test_lowlevel_available(self): 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(attr, 'available'), diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index dd00ad66..e577851f 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 @@ -19,6 +19,7 @@ from __future__ import annotations +import sys import unittest from PIL import Image @@ -44,6 +45,21 @@ def test_open_image(self): self.assertEqual(osr.dimensions, (300, 250)) self.assertEqual(repr(osr), 'ImageSlide(%r)' % img) + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self): + 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): + 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): with Image.open(file_path('boxes.png')) as img: osr = ImageSlide(img) diff --git a/tests/test_openslide.py b/tests/test_openslide.py index ab134f33..b863fb97 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 @@ -60,12 +60,27 @@ def test_open(self): 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)) + self.assertRaises(ArgumentError, lambda: OpenSlide(3)) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('unopenable.tiff') ) + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self): + 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): + 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): osr = OpenSlide(file_path('boxes.tiff')) props = osr.properties From cf628e0bbed9cd398870513c968e8170b5c9aaa6 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 01:54:58 -0700 Subject: [PATCH 086/137] lowlevel: accept bytes in type hints for _utf8_p arguments The high-level API doesn't accept bytes for any of the affected functionality, but lowlevel does, so encode that in the type signatures. Signed-off-by: Benjamin Gilbert --- openslide/lowlevel.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 5efa5fa9..48963057 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -475,7 +475,7 @@ def read_icc_profile(slide: _OpenSlide) -> bytes | None: 'openslide_get_property_names', POINTER(c_char_p), [_OpenSlide], _check_name_list ) -get_property_value: _Func[[_OpenSlide, str], str] = _func( +get_property_value: _Func[[_OpenSlide, str | bytes], str] = _func( 'openslide_get_property_value', c_char_p, [_OpenSlide, _utf8_p] ) @@ -487,7 +487,7 @@ def read_icc_profile(slide: _OpenSlide) -> bytes | None: ) _get_associated_image_dimensions: _Func[ - [_OpenSlide, str, _Pointer[c_int64], _Pointer[c_int64]], None + [_OpenSlide, str | bytes, _Pointer[c_int64], _Pointer[c_int64]], None ] = _func( 'openslide_get_associated_image_dimensions', None, @@ -496,46 +496,54 @@ def read_icc_profile(slide: _OpenSlide) -> bytes | None: @_wraps_funcs([_get_associated_image_dimensions]) -def get_associated_image_dimensions(slide: _OpenSlide, name: str) -> tuple[int, int]: +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, str, _Pointer[c_uint32]], None] = _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: _OpenSlide, name: str) -> Image.Image: +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[[_OpenSlide, str], int] = _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[[_OpenSlide, str, _Pointer[c_char]], None] = ( - _func( - 'openslide_read_associated_image_icc_profile', - None, - [_OpenSlide, _utf8_p, POINTER(c_char)], - minimum_version='4.0.0', - ) +_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)], + minimum_version='4.0.0', ) @_wraps_funcs( [get_associated_image_icc_profile_size, _read_associated_image_icc_profile] ) -def read_associated_image_icc_profile(slide: _OpenSlide, name: str) -> bytes | None: +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 From 5f49c0173cd800b6ce0041615d65cb6d7d170fdb Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 19:05:43 -0700 Subject: [PATCH 087/137] Export type hints from openslide package Now that we've fully populated type hints, add py.typed marker (and also "Typing :: Typed" Trove classifier) so type checkers will use them. Signed-off-by: Benjamin Gilbert --- openslide/py.typed | 0 pyproject.toml | 1 + setup.py | 3 +++ 3 files changed, 4 insertions(+) create mode 100644 openslide/py.typed 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 index 8fd7be02..42ee741b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", ] requires-python = ">= 3.8" dependencies = ["Pillow"] diff --git a/setup.py b/setup.py index a4702e72..4a5e532c 100644 --- a/setup.py +++ b/setup.py @@ -21,4 +21,7 @@ # tag wheel for Limited API 'bdist_wheel': {'py_limited_api': 'cp311'} if _abi3 else {}, }, + package_data={ + 'openslide': ['py.typed'], + }, ) From f9a42529ec6afc7a32547d4002b0a6292577ef3f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 19:54:59 -0700 Subject: [PATCH 088/137] tests: reparent abstract base classes as a member of an _Abstract class To prevent abstract base classes from being found by test discovery, we've been avoiding making them subclasses of TestCase, and then using multiple inheritance in the concrete subclasses. However, this confuses type checking when the ABC calls TestCase methods. Instead, make the ABC a TestCase subclass, but make it a member of another class so test discovery won't find it. Suggested-by: https://stackoverflow.com/a/50176291/981954 Signed-off-by: Benjamin Gilbert --- tests/test_deepzoom.py | 167 ++++++++++++++++++++------------------- tests/test_imageslide.py | 16 ++-- tests/test_openslide.py | 20 ++--- 3 files changed, 106 insertions(+), 97 deletions(-) diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index af1cd503..80f3e951 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -27,90 +27,95 @@ 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): + 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(_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 e577851f..81f84921 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -80,15 +80,17 @@ def test_context_manager(self): self.assertRaises(ValueError, lambda: osr.level_dimensions) -class _SlideTest: - def setUp(self): - self.osr = ImageSlide(file_path(self.FILENAME)) +class _Abstract: + # nested class to prevent the test runner from finding it + class SlideTest(unittest.TestCase): + def setUp(self): + self.osr = ImageSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self): + self.osr.close() -class TestImage(_SlideTest, unittest.TestCase): +class TestImage(_Abstract.SlideTest): FILENAME = 'boxes.png' def test_repr(self): @@ -142,7 +144,7 @@ def test_set_cache(self): 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): diff --git a/tests/test_openslide.py b/tests/test_openslide.py index b863fb97..55768fc5 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -98,15 +98,17 @@ def test_context_manager(self): 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): + def setUp(self): + self.osr = OpenSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self): + self.osr.close() -class TestSlide(_SlideTest, unittest.TestCase): +class TestSlide(_Abstract.SlideTest): FILENAME = 'boxes.tiff' def test_repr(self): @@ -186,7 +188,7 @@ def test_set_cache(self): self.assertRaises(TypeError, lambda: self.osr.set_cache(3)) -class TestAperioSlide(_SlideTest, unittest.TestCase): +class TestAperioSlide(_Abstract.SlideTest): FILENAME = 'small.svs' def test_associated_images(self): @@ -220,7 +222,7 @@ 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): @@ -232,7 +234,7 @@ 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): From 26ef392f57f02765d8341458e82daef56742b6ae Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 22:37:00 -0700 Subject: [PATCH 089/137] tests: add type hints Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 2 +- tests/common.py | 4 +- tests/test_base.py | 4 +- tests/test_deepzoom.py | 32 ++++++++-------- tests/test_imageslide.py | 42 +++++++++++---------- tests/test_openslide.py | 80 ++++++++++++++++++++++++---------------- 6 files changed, 94 insertions(+), 70 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dca2fb10..d164f030 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,7 @@ repos: - id: mypy name: Check Python types additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|tests/.*|examples/deepzoom/.*)$" + exclude: "^(doc/.*|examples/deepzoom/.*)$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/tests/common.py b/tests/common.py index 9efd1c26..28bc8bfb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -30,9 +30,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] 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/test_base.py b/tests/test_base.py index d03ce7cf..2ed9116e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -28,13 +28,13 @@ 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) diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index 80f3e951..1c67ab06 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -30,24 +30,26 @@ class _Abstract: # nested class to prevent the test runner from finding it class BoxesDeepZoomTest(unittest.TestCase): - def setUp(self): + 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): + def tearDown(self) -> None: self.osr.close() - def test_repr(self): + def test_repr(self) -> None: self.assertEqual( repr(self.dz), - ( - 'DeepZoomGenerator(%r, tile_size=254, overlap=1, ' - + 'limit_bounds=False)' - ) + 'DeepZoomGenerator(%r, tile_size=254, overlap=1, limit_bounds=False)' % self.osr, ) - def test_metadata(self): + def test_metadata(self) -> None: self.assertEqual(self.dz.level_count, 10) self.assertEqual(self.dz.tile_count, 11) self.assertEqual( @@ -81,31 +83,31 @@ def test_metadata(self): ), ) - def test_get_tile(self): + def test_get_tile(self) -> None: self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) - def test_tile_color_profile(self): + 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): + 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): + 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): + 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): + def test_get_tile_dimensions(self) -> None: self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) - def test_get_dzi(self): + def test_get_dzi(self) -> None: self.assertTrue( 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') ) diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index 81f84921..051263ee 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -29,16 +29,16 @@ 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: @@ -49,18 +49,18 @@ def test_open_image(self): sys.getfilesystemencoding() == 'utf-8', 'Python filesystem encoding is not UTF-8', ) - def test_unicode_path(self): + 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): + 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): + def test_operations_on_closed_handle(self) -> None: with Image.open(file_path('boxes.png')) as img: osr = ImageSlide(img) osr.close() @@ -72,7 +72,7 @@ def test_operations_on_closed_handle(self): # 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 @@ -83,20 +83,23 @@ def test_context_manager(self): class _Abstract: # nested class to prevent the test runner from finding it class SlideTest(unittest.TestCase): - def setUp(self): + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.FILENAME is not None self.osr = ImageSlide(file_path(self.FILENAME)) - def tearDown(self): + def tearDown(self) -> None: self.osr.close() 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)) @@ -108,7 +111,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 @@ -117,29 +121,29 @@ 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)) @@ -147,7 +151,7 @@ def test_set_cache(self): 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 55768fc5..2be656fd 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -22,6 +22,7 @@ from ctypes import ArgumentError import re import sys +from typing import Any import unittest from common import file_path @@ -37,31 +38,39 @@ 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(ArgumentError, lambda: OpenSlide(None)) - self.assertRaises(ArgumentError, 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') ) @@ -70,18 +79,18 @@ def test_open(self): sys.getfilesystemencoding() == 'utf-8', 'Python filesystem encoding is not UTF-8', ) - def test_unicode_path(self): + 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): + 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): + def test_operations_on_closed_handle(self) -> None: osr = OpenSlide(file_path('boxes.tiff')) props = osr.properties associated = osr.associated_images @@ -91,7 +100,7 @@ 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) @@ -101,20 +110,23 @@ def test_context_manager(self): class _Abstract: # nested class to prevent the test runner from finding it class SlideTest(unittest.TestCase): - def setUp(self): + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.FILENAME is not None self.osr = OpenSlide(file_path(self.FILENAME)) - def tearDown(self): + def tearDown(self) -> None: self.osr.close() 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)) @@ -130,7 +142,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__ @@ -144,7 +156,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 @@ -153,18 +166,18 @@ 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)) ) @@ -172,26 +185,30 @@ def test_read_region_bad_size(self): @unittest.skipIf(sys.maxsize < 1 << 32, '32-bit Python') # 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(_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__ @@ -200,7 +217,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( @@ -208,7 +225,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 @@ -225,7 +242,8 @@ def test_color_profile(self): 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'] @@ -237,7 +255,7 @@ def test_color_profile(self): 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)) @@ -247,7 +265,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 From 9eb3919eb49518ff7b8d6d2d913b2e15496ca64b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 18:26:31 -0700 Subject: [PATCH 090/137] examples/deepzoom: add type hints Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 4 +- examples/deepzoom/deepzoom_multiserver.py | 84 +++++++++++----- examples/deepzoom/deepzoom_server.py | 57 ++++++++--- examples/deepzoom/deepzoom_tile.py | 115 +++++++++++++++------- 4 files changed, 184 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d164f030..293bbeb7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,8 +58,8 @@ repos: hooks: - id: mypy name: Check Python types - additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|examples/deepzoom/.*)$" + additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] + exclude: "^doc/.*$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index de0c2061..82b4734a 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -24,18 +24,24 @@ from argparse import ArgumentParser import base64 from collections import OrderedDict +from collections.abc import Callable from io import BytesIO import os 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] import openslide else: import openslide @@ -62,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: str + 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: str | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomMultiServer(__name__) app.config.from_mapping( SLIDE_DIR='.', SLIDE_CACHE_SIZE=10, @@ -99,7 +131,7 @@ def create_app(config=None, config_file=None): ) # Helper functions - def get_slide(path): + def get_slide(path: str) -> AnnotatedDeepZoomGenerator: path = os.path.abspath(os.path.join(app.basedir, path)) if not path.startswith(app.basedir + os.path.sep): # Directory traversal @@ -115,11 +147,11 @@ def get_slide(path): # 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): + def slide(path: str) -> str: slide = get_slide(path) slide_url = url_for('dzi', path=path) return render_template( @@ -130,7 +162,7 @@ def slide(path): ) @app.route('/.dzi') - def dzi(path): + def dzi(path: str) -> Response: slide = get_slide(path) format = app.config['DEEPZOOM_FORMAT'] resp = make_response(slide.get_dzi(format)) @@ -138,7 +170,7 @@ def dzi(path): return resp @app.route('/_files//_.') - def tile(path, level, col, row, format): + def tile(path: str, level: int, col: int, row: int, format: str) -> Response: slide = get_slide(path) format = format.lower() if format != 'jpeg' and format != 'png': @@ -165,19 +197,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[str, 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: str) -> AnnotatedDeepZoomGenerator: with self._lock: if path in self._cache: # Move to end of LRU @@ -188,7 +228,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] @@ -204,7 +244,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 @@ -215,7 +255,7 @@ def _get_transform(self, image): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -232,10 +272,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 @@ -246,9 +286,9 @@ def xfrm(img): class _Directory: - def __init__(self, basedir, relpath=''): + def __init__(self, basedir: str, relpath: str = ''): self.name = os.path.basename(relpath) - self.children = [] + self.children: list[_Directory | _SlideFile] = [] 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) @@ -261,7 +301,7 @@ def __init__(self, basedir, relpath=''): class _SlideFile: - def __init__(self, relpath): + def __init__(self, relpath: str): self.name = os.path.basename(relpath) self.url_path = relpath diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index 0b82aeda..1512460f 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -23,26 +23,32 @@ from argparse import ArgumentParser import base64 +from collections.abc import Callable from io import BytesIO import os import re +from typing import TYPE_CHECKING, Any, Literal, Mapping 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] 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' @@ -64,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: str | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomServer(__name__) app.config.from_mapping( DEEPZOOM_SLIDE=None, DEEPZOOM_FORMAT='jpeg', @@ -117,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 @@ -131,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)) @@ -142,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 @@ -170,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': @@ -185,7 +214,7 @@ def get_transform(image, mode): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -202,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. diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index aad834d9..211b9111 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -25,29 +25,36 @@ 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 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] 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' @@ -69,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], str] | 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: str, + tile_size: int, + overlap: int, + limit_bounds: bool, + quality: int, + color_mode: ColorMode, ): Process.__init__(self, name='TileWorker') self.daemon = True @@ -85,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() @@ -107,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( @@ -117,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 @@ -128,7 +160,7 @@ def _get_transform(self, image): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -145,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 @@ -161,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: str, + format: str, + associated: str | None, + queue: TileQueue, + ): self._dz = dz self._basename = basename self._format = format @@ -169,11 +208,11 @@ 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): @@ -188,7 +227,7 @@ def _write_tiles(self): 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: @@ -201,11 +240,11 @@ def _tile_done(self): if count == total: print(file=sys.stderr) - def _write_dzi(self): + def _write_dzi(self) -> None: with open('%s.dzi' % self._basename, 'w') as fh: fh.write(self.get_dzi()) - def get_dzi(self): + def get_dzi(self) -> str: return self._dz.get_dzi(self._format) @@ -214,16 +253,16 @@ class DeepZoomStaticTiler: def __init__( self, - slidepath, - basename, - format, - tile_size, - overlap, - limit_bounds, - quality, - color_mode, - workers, - with_viewer, + slidepath: str, + basename: str, + 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 @@ -234,11 +273,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, @@ -250,7 +289,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: @@ -259,7 +298,7 @@ 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 @@ -277,14 +316,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 @@ -321,13 +360,13 @@ def _write_html(self): with open(os.path.join(self._basename, 'index.html'), 'w') as fh: fh.write(data) - def _write_static(self): + def _write_static(self) -> None: basesrc = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') basedst = os.path.join(self._basename, 'static') self._copydir(basesrc, basedst) self._copydir(os.path.join(basesrc, 'images'), os.path.join(basedst, 'images')) - def _copydir(self, src, dest): + def _copydir(self, src: str, dest: str) -> None: if not os.path.exists(dest): os.makedirs(dest) for name in os.listdir(src): @@ -336,11 +375,11 @@ def _copydir(self, src, dest): shutil.copy(srcpath, os.path.join(dest, 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() From 7c885b297c8bd8b17561e716759e7b90a230d77f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 08:58:41 -0700 Subject: [PATCH 091/137] doc: drop settings for unused Sphinx output formats Signed-off-by: Benjamin Gilbert --- doc/conf.py | 92 ----------------------------------------------------- 1 file changed, 92 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4cdc306d..79718126 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -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 = { From 8acd4d9f64c83b622fa8f79b551f509bd6c63c1a Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 16 Oct 2024 08:51:44 -0700 Subject: [PATCH 092/137] doc: add type hints to Python sources Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 1 - doc/jekyll_fix.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 293bbeb7..7d43bcbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,6 @@ repos: - id: mypy name: Check Python types additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] - exclude: "^doc/.*$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/doc/jekyll_fix.py b/doc/jekyll_fix.py index b479e00e..798b8897 100644 --- a/doc/jekyll_fix.py +++ b/doc/jekyll_fix.py @@ -27,6 +27,7 @@ import os +from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.console import bold @@ -41,7 +42,7 @@ 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 @@ -82,5 +83,5 @@ def remove_path_underscores(app, exception): logger.info('done') -def setup(app): +def setup(app: Sphinx) -> None: app.connect('build-finished', remove_path_underscores) From 68a2bf150523d0509e9ebe494bbbff8c9f4773ca Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 09:59:42 -0700 Subject: [PATCH 093/137] templates/python-bump: also bump hardcoded versions in workflow Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/python-bump.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/python-bump.md b/.github/ISSUE_TEMPLATE/python-bump.md index 6daf89ea..8da37b22 100644 --- a/.github/ISSUE_TEMPLATE/python-bump.md +++ b/.github/ISSUE_TEMPLATE/python-bump.md @@ -3,7 +3,7 @@ - Update Git main - [ ] `git checkout main` - [ ] In `pyproject.toml`, add classifier for new Python version and update `tool.black.target-version` - - [ ] Add new Python version to lists in `.github/workflows/python.yml` + - [ ] 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) From b49133e25cbebbdb5a3cce7feb2f90fd9d7b8f4f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 09:54:24 -0700 Subject: [PATCH 094/137] pyproject: update for Python 3.13 Signed-off-by: Benjamin Gilbert --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42ee741b..6bb05bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "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", ] @@ -44,7 +45,7 @@ version = {attr = "openslide._version.__version__"} [tool.black] skip-string-normalization = true -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py38", "py39", "py310", "py311", "py312", "py313"] # https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 # also ignore: From 754a2d95c0559b8679e48e8ce30251384e580c39 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 16 Oct 2024 08:52:04 -0700 Subject: [PATCH 095/137] doc: explicitly require Sphinx >= 1.6 Signed-off-by: Benjamin Gilbert --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 79718126..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. From eb5bd0851328d13851cb343cd7a9bf94817e4f25 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 01:28:44 -0700 Subject: [PATCH 096/137] doc: stop using "list" to describe a tuple Several properties return tuples of items but are documented to return lists. Fix the docs. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 8 ++++---- openslide/__init__.py | 12 ++++++------ openslide/deepzoom.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 03799050..54bd125e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -132,12 +132,12 @@ OpenSlide objects .. 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``. .. 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``. .. attribute:: properties @@ -409,12 +409,12 @@ Deep Zoom or a similar format. .. 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``. .. 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) diff --git a/openslide/__init__.py b/openslide/__init__.py index 65022b41..8994bcdf 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -98,7 +98,7 @@ def level_count(self) -> int: @property def level_dimensions(self) -> tuple[tuple[int, int], ...]: - """A list of (width, height) tuples, one for each level of the image. + """A tuple of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" raise NotImplementedError @@ -110,7 +110,7 @@ def dimensions(self) -> tuple[int, int]: @property def level_downsamples(self) -> tuple[float, ...]: - """A list of downsampling factors for each level of the image. + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" raise NotImplementedError @@ -217,7 +217,7 @@ def level_count(self) -> int: @property def level_dimensions(self) -> tuple[tuple[int, int], ...]: - """A list of (width, height) tuples, one for each level of the image. + """A tuple of (width, height) tuples, one for each level of the image. level_dimensions[n] contains the dimensions of level n.""" return tuple( @@ -226,7 +226,7 @@ def level_dimensions(self) -> tuple[tuple[int, int], ...]: @property def level_downsamples(self) -> tuple[float, ...]: - """A list of downsampling factors for each level of the image. + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" return tuple( @@ -402,7 +402,7 @@ def level_count(self) -> Literal[1]: @property def level_dimensions(self) -> tuple[tuple[int, int]]: - """A list of (width, height) tuples, one for each level of the image. + """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: @@ -411,7 +411,7 @@ def level_dimensions(self) -> tuple[tuple[int, int]]: @property def level_downsamples(self) -> tuple[float]: - """A list of downsampling factors for each level of the image. + """A tuple of downsampling factors for each level of the image. level_downsample[n] contains the downsample factor of level n.""" return (1.0,) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 623e3adb..9d0b3ab8 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -163,12 +163,12 @@ def level_count(self) -> int: @property def level_tiles(self) -> tuple[tuple[int, int], ...]: - """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.""" return self._t_dimensions @property def level_dimensions(self) -> tuple[tuple[int, int], ...]: - """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.""" return self._z_dimensions @property From ad0c4539d0fd3877f0642c4fc5f668851d8ad774 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 01:32:17 -0700 Subject: [PATCH 097/137] doc: document attribute types Drop some redundant description text. Don't document the types of the standard OpenSlide property names, since they're all strings and would clutter the list. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 54bd125e..acdd6af1 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -126,27 +126,36 @@ OpenSlide objects 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 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 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 @@ -156,11 +165,14 @@ OpenSlide objects 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) @@ -403,20 +415,28 @@ Deep Zoom or a similar format. 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 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 tuple of ``(pixels_x, pixels_y)`` tuples for each Deep Zoom level. ``level_dimensions[k]`` are the dimensions of level ``k``. + :type: tuple[tuple[int, int], ...] + .. method:: get_dzi(format) Return a string containing the XML metadata for the Deep Zoom ``.dzi`` From 959f7022fe1c8216ba076b6dab9099671a234116 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 01:38:22 -0700 Subject: [PATCH 098/137] doc: add type hints to constructors and methods Remove type information from parameter documentation, since it's redundant now. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 81 +++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index acdd6af1..862c1a77 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -93,7 +93,7 @@ OpenSlide objects .. module:: openslide -.. class:: OpenSlide(filename) +.. class:: OpenSlide(filename: str | bytes | ~os.PathLike[typing.Any]) An open whole-slide image. @@ -106,12 +106,12 @@ OpenSlide objects 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` @@ -119,7 +119,7 @@ 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 @@ -174,42 +174,42 @@ OpenSlide objects :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. 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. - :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. @@ -266,7 +266,7 @@ reusing it for multiple slide regions:: Caching ------- -.. class:: OpenSlideCache(capacity) +.. class:: OpenSlideCache(capacity: int) An in-memory tile cache. @@ -274,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 @@ -366,7 +366,7 @@ Exceptions Wrapping a Pillow Image ======================= -.. class:: ImageSlide(file) +.. class:: ImageSlide(file: str | bytes | ~os.PathLike[typing.Any] | ~PIL.Image.Image) A wrapper around an :class:`Image ` object that provides an :class:`OpenSlide`-compatible API. @@ -374,12 +374,12 @@ Wrapping a Pillow Image :param file: a filename or :class:`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 @@ -397,19 +397,18 @@ 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. :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: ``True`` to render only the non-empty slide region .. attribute:: level_count @@ -437,23 +436,23 @@ Deep Zoom or a similar format. :type: tuple[tuple[int, int], ...] - .. method:: get_dzi(format) + .. 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. - :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 @@ -461,16 +460,16 @@ Deep Zoom or a similar format. 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 From 21841c50e2d8ef967d608d452ec4986e157a45cd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 18 Oct 2024 01:39:16 -0700 Subject: [PATCH 099/137] doc: document AbstractSlide It's used in the type signature of the DeepZoomGenerator constructor because DeepZoomGenerator also supports user-supplied AbstractSlide subclasses. Ensure the hyperlink has somewhere to point, and update the DeepZoomGenerator description to mention user-provided implementations. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 862c1a77..1e914951 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -366,6 +366,10 @@ Exceptions Wrapping a Pillow Image ======================= +.. class:: AbstractSlide + + The abstract base class of :class:`OpenSlide` and :class:`ImageSlide`. + .. class:: ImageSlide(file: str | bytes | ~os.PathLike[typing.Any] | ~PIL.Image.Image) A wrapper around an :class:`Image ` object that @@ -400,8 +404,9 @@ Deep Zoom or a similar format. .. 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. + :class:`OpenSlide ` object, + :class:`ImageSlide ` object, or user-provided + instance of :class:`~openslide.AbstractSlide`. :param osr: the slide object :param tile_size: the width and height of a single tile. For best viewer From 1a1cb1065bec4df55ecd7784d026046ee7d935af Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 26 Oct 2024 08:36:27 -0700 Subject: [PATCH 100/137] doc: simplify cross-references No rendering changes intended. Signed-off-by: Benjamin Gilbert --- doc/index.rst | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 1e914951..9efdc64b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -100,9 +100,9 @@ OpenSlide objects 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. @@ -161,7 +161,7 @@ OpenSlide objects 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. @@ -176,8 +176,8 @@ OpenSlide objects .. 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. @@ -194,8 +194,8 @@ OpenSlide objects .. 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 size: the maximum size of the thumbnail as a ``(width, height)`` tuple @@ -347,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 @@ -372,10 +372,10 @@ Wrapping a Pillow Image .. class:: ImageSlide(file: str | bytes | ~os.PathLike[typing.Any] | ~PIL.Image.Image) - A wrapper around an :class:`Image ` object that - provides an :class:`OpenSlide`-compatible API. + A wrapper around an :class:`~PIL.Image.Image` object that provides an + :class:`OpenSlide`-compatible API. - :param file: a filename or :class:`Image ` object + :param file: a filename or :class:`~PIL.Image.Image` object :raises OSError: if the file cannot be opened .. function:: open_slide(filename: str | bytes | ~os.PathLike[typing.Any]) -> OpenSlide | ImageSlide @@ -403,17 +403,16 @@ Deep Zoom or a similar format. .. class:: DeepZoomGenerator(osr: AbstractSlide, tile_size: int = 254, overlap: int = 1, limit_bounds: bool = False) - A Deep Zoom generator that wraps an - :class:`OpenSlide ` object, - :class:`ImageSlide ` object, or user-provided - instance of :class:`~openslide.AbstractSlide`. + 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 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: ``True`` to render only the non-empty slide region + :param limit_bounds: :obj:`True` to render only the non-empty slide region .. attribute:: level_count @@ -451,7 +450,7 @@ Deep Zoom or a similar format. .. 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 level: the Deep Zoom level :param address: the address of the tile within the level as a @@ -463,7 +462,7 @@ Deep Zoom or a similar format. ` arguments corresponding to the specified tile. - Most applications should use :meth:`get_tile()` instead. + Most applications should use :meth:`get_tile` instead. :param level: the Deep Zoom level :param address: the address of the tile within the level as a From 0936865846d244ef513754145c1d3b088b7279dc Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 8 Oct 2024 23:35:28 -0700 Subject: [PATCH 101/137] examples/deepzoom: enforce --format enum in argparse Give clearer feedback if the argument is invalid. For the WSGI programs we need to continue enforcing validity outside of argparse, since the value may be set by other means. Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_multiserver.py | 2 +- examples/deepzoom/deepzoom_server.py | 2 +- examples/deepzoom/deepzoom_tile.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 82b4734a..3869f3bb 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -356,8 +356,8 @@ def __init__(self, relpath: str): parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='DEEPZOOM_FORMAT', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index 1512460f..555997db 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -293,8 +293,8 @@ def xfrm(img: Image.Image) -> None: parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='DEEPZOOM_FORMAT', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 211b9111..46d3b542 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -426,9 +426,9 @@ def _shutdown(self) -> None: parser.add_argument( '-f', '--format', - metavar='{jpeg|png}', dest='format', default='jpeg', + choices=['jpeg', 'png'], help='image format for tiles [jpeg]', ) parser.add_argument( From 32bb27c7e0b165a8db8485764a8137fffa03617c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 26 Oct 2024 13:45:38 -0700 Subject: [PATCH 102/137] Improve error message when accessing a closed ImageSlide Suggested-by: Jan Harkes Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 8994bcdf..4f354b50 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -406,7 +406,7 @@ def level_dimensions(self) -> tuple[tuple[int, int]]: level_dimensions[n] contains the dimensions of level n.""" if self._image is None: - raise ValueError('Passing closed slide object') + raise ValueError('Cannot read from a closed slide') return (self._image.size,) @property @@ -444,7 +444,7 @@ def read_region( level: the level number. size: (width, height) tuple giving the region size.""" if self._image is None: - raise ValueError('Passing closed slide object') + raise ValueError('Cannot read from a closed slide') if level != 0: raise OpenSlideError("Invalid level") if ['fail' for s in size if s < 0]: From d821e184136f805e67490240a456a5ca505972d0 Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Fri, 17 Nov 2023 14:58:58 -0500 Subject: [PATCH 103/137] Don't allow instantiating AbstractSlide and _OpenSlideMap base classes Decorate methods with '@abstractmethod' to prevent instantiation unless all abstract methods and properties are overridden. Signed-off-by: Jan Harkes Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 4f354b50..39bdc5ab 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -25,6 +25,7 @@ from __future__ import annotations +from abc import ABCMeta, abstractmethod from io import BytesIO from types import TracebackType from typing import Iterator, Literal, Mapping, TypeVar @@ -62,7 +63,7 @@ _T = TypeVar('_T') -class AbstractSlide: +class AbstractSlide(metaclass=ABCMeta): """The base class of a slide object.""" def __init__(self) -> None: @@ -81,22 +82,26 @@ def __exit__( return False @classmethod + @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 + @abstractmethod def close(self) -> None: """Close the slide.""" raise NotImplementedError @property + @abstractmethod def level_count(self) -> int: """The number of levels in the image.""" raise NotImplementedError @property + @abstractmethod def level_dimensions(self) -> tuple[tuple[int, int], ...]: """A tuple of (width, height) tuples, one for each level of the image. @@ -109,6 +114,7 @@ def dimensions(self) -> tuple[int, int]: return self.level_dimensions[0] @property + @abstractmethod def level_downsamples(self) -> tuple[float, ...]: """A tuple of downsampling factors for each level of the image. @@ -116,6 +122,7 @@ def level_downsamples(self) -> tuple[float, ...]: raise NotImplementedError @property + @abstractmethod def properties(self) -> Mapping[str, str]: """Metadata about the image. @@ -123,6 +130,7 @@ def properties(self) -> Mapping[str, str]: raise NotImplementedError @property + @abstractmethod def associated_images(self) -> Mapping[str, Image.Image]: """Images associated with this whole-slide image. @@ -136,10 +144,12 @@ def color_profile(self) -> ImageCms.ImageCmsProfile | None: return None return ImageCms.getOpenProfile(BytesIO(self._profile)) + @abstractmethod def get_best_level_for_downsample(self, downsample: float) -> int: """Return the best level for displaying the given downsample.""" raise NotImplementedError + @abstractmethod def read_region( self, location: tuple[int, int], level: int, size: tuple[int, int] ) -> Image.Image: @@ -151,6 +161,7 @@ def read_region( size: (width, height) tuple giving the region size.""" raise NotImplementedError + @abstractmethod def set_cache(self, cache: OpenSlideCache) -> None: """Use the specified cache to store recently decoded slide tiles. @@ -299,6 +310,7 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[str]: return iter(self._keys()) + @abstractmethod def _keys(self) -> list[str]: # Private method; always returns list. raise NotImplementedError() From dbd779dc4b581878c749e81e9d519e675b6899b4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 26 Oct 2024 14:25:55 -0700 Subject: [PATCH 104/137] Make AbstractSlide.set_cache() non-abstract It was added in 1.2.0, so any older third-party subclass may not implement the method. In addition, most subclasses will want this method to do nothing, matching ImageSlide's implementation. Make the default implementation do nothing and remove the method override from ImageSlide. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 39bdc5ab..60a32b41 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -161,12 +161,13 @@ def read_region( size: (width, height) tuple giving the region size.""" raise NotImplementedError - @abstractmethod - def set_cache(self, cache: OpenSlideCache) -> None: + 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: tuple[int, int]) -> Image.Image: """Return a PIL.Image containing an RGB thumbnail of the image. @@ -486,14 +487,6 @@ def read_region( tile.info['icc_profile'] = self._profile return tile - def set_cache(self, cache: OpenSlideCache) -> None: - """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: lowlevel.Filename) -> OpenSlide | ImageSlide: """Open a whole-slide or regular image. From 7d1be91f2c4afd15cafffc841e6b23326d2a7f6f Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Fri, 17 Nov 2023 15:24:28 -0500 Subject: [PATCH 105/137] lowlevel: check for errors after calling set_cache openslide_set_cache() doesn't generate any errors, but that's an implementation detail, and for consistency we should check that an error wasn't previously set. Signed-off-by: Jan Harkes Signed-off-by: Benjamin Gilbert --- openslide/lowlevel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 48963057..c24ca7c9 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -568,7 +568,6 @@ def read_associated_image_icc_profile( 'openslide_set_cache', None, [_OpenSlide, _OpenSlideCache], - None, minimum_version='4.0.0', ) From d63d015b79ce3400a069a4d08e93e82c17b0ed24 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 16 Oct 2024 09:05:54 -0700 Subject: [PATCH 106/137] doc/jekyll_fix: switch from os.path to pathlib Path.walk() isn't available before Python 3.12. Signed-off-by: Benjamin Gilbert --- doc/jekyll_fix.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/doc/jekyll_fix.py b/doc/jekyll_fix.py index 798b8897..ef6555c5 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 @@ -26,6 +27,7 @@ from __future__ import annotations import os +from pathlib import Path from sphinx.application import Sphinx from sphinx.util import logging @@ -49,37 +51,33 @@ def remove_path_underscores(app: Sphinx, exception: Exception | None) -> None: 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') From 012fbb13319a9f266189d2b7cb1b50b3b3d17a22 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 8 Oct 2024 23:34:34 -0700 Subject: [PATCH 107/137] examples/deepzoom: switch from os.path to pathlib Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_multiserver.py | 55 +++++++++++---------- examples/deepzoom/deepzoom_server.py | 10 ++-- examples/deepzoom/deepzoom_tile.py | 60 +++++++++++------------ 3 files changed, 65 insertions(+), 60 deletions(-) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 3869f3bb..46f51743 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 @@ -27,6 +27,7 @@ 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 @@ -82,7 +83,7 @@ class DeepZoomMultiServer(Flask): - basedir: str + basedir: Path cache: _SlideCache @@ -94,7 +95,7 @@ class AnnotatedDeepZoomGenerator(DeepZoomGenerator): def create_app( config: dict[str, Any] | None = None, - config_file: str | None = None, + config_file: Path | None = None, ) -> Flask: # Create and configure app app = DeepZoomMultiServer(__name__) @@ -116,7 +117,7 @@ def create_app( 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', @@ -131,16 +132,18 @@ def create_app( ) # Helper functions - def get_slide(path: str) -> AnnotatedDeepZoomGenerator: - 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) @@ -152,7 +155,7 @@ def index() -> str: @app.route('/') def slide(path: str) -> str: - slide = get_slide(path) + slide = get_slide(PurePath(path)) slide_url = url_for('dzi', path=path) return render_template( 'slide-fullpage.html', @@ -163,7 +166,7 @@ def slide(path: str) -> str: @app.route('/.dzi') def dzi(path: str) -> Response: - slide = get_slide(path) + slide = get_slide(PurePath(path)) format = app.config['DEEPZOOM_FORMAT'] resp = make_response(slide.get_dzi(format)) resp.mimetype = 'application/xml' @@ -171,7 +174,7 @@ def dzi(path: str) -> Response: @app.route('/_files//_.') def tile(path: str, level: int, col: int, row: int, format: str) -> Response: - slide = get_slide(path) + slide = get_slide(PurePath(path)) format = format.lower() if format != 'jpeg' and format != 'png': # Not supported by Deep Zoom @@ -208,7 +211,7 @@ def __init__( self.dz_opts = dz_opts self.color_mode = color_mode self._lock = Lock() - self._cache: OrderedDict[str, AnnotatedDeepZoomGenerator] = OrderedDict() + self._cache: OrderedDict[Path, AnnotatedDeepZoomGenerator] = OrderedDict() # Share a single tile cache among all slide handles, if supported try: self._tile_cache: OpenSlideCache | None = OpenSlideCache( @@ -217,7 +220,7 @@ def __init__( except OpenSlideVersionError: self._tile_cache = None - def get(self, path: str) -> AnnotatedDeepZoomGenerator: + def get(self, path: Path) -> AnnotatedDeepZoomGenerator: with self._lock: if path in self._cache: # Move to end of LRU @@ -286,13 +289,14 @@ def xfrm(img: Image.Image) -> None: class _Directory: - def __init__(self, basedir: str, relpath: str = ''): - self.name = os.path.basename(relpath) + _DEFAULT_RELPATH = PurePath('.') + + def __init__(self, basedir: Path, relpath: PurePath = _DEFAULT_RELPATH): + self.name = relpath.name self.children: list[_Directory | _SlideFile] = [] - 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): + 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) @@ -301,9 +305,9 @@ def __init__(self, basedir: str, relpath: str = ''): class _SlideFile: - def __init__(self, relpath: str): - 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__': @@ -336,7 +340,7 @@ def __init__(self, relpath: str): ), ) 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', @@ -396,6 +400,7 @@ def __init__(self, relpath: str): 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 555997db..f57579e9 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -26,6 +26,7 @@ from collections.abc import Callable from io import BytesIO import os +from pathlib import Path import re from typing import TYPE_CHECKING, Any, Literal, Mapping from unicodedata import normalize @@ -93,7 +94,7 @@ class DeepZoomServer(Flask): def create_app( config: dict[str, Any] | None = None, - config_file: str | None = None, + config_file: Path | None = None, ) -> Flask: # Create and configure app app = DeepZoomServer(__name__) @@ -113,9 +114,9 @@ def create_app( 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', @@ -273,7 +274,7 @@ def xfrm(img: Image.Image) -> None: ), ) 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', @@ -333,6 +334,7 @@ def xfrm(img: Image.Image) -> None: 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 46d3b542..05e4d9a7 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 @@ -31,6 +31,7 @@ from multiprocessing import JoinableQueue, Process import multiprocessing.queues import os +from pathlib import Path import re import shutil import sys @@ -87,7 +88,7 @@ 'ignore', ] TileQueue: TypeAlias = multiprocessing.queues.JoinableQueue[ - tuple[str | None, int, tuple[int, int], str] | None + tuple[str | None, int, tuple[int, int], Path] | None ] Transform: TypeAlias = Callable[[Image.Image], None] @@ -98,7 +99,7 @@ class TileWorker(Process): def __init__( self, queue: TileQueue, - slidepath: str, + slidepath: Path, tile_size: int, overlap: int, limit_bounds: bool, @@ -196,7 +197,7 @@ class DeepZoomImageTiler: def __init__( self, dz: DeepZoomGenerator, - basename: str, + basename: Path, format: str, associated: str | None, queue: TileQueue, @@ -214,16 +215,15 @@ def run(self) -> None: 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() @@ -241,7 +241,7 @@ def _tile_done(self) -> None: print(file=sys.stderr) def _write_dzi(self) -> None: - with open('%s.dzi' % self._basename, 'w') as fh: + with self._basename.with_name(self._basename.name + '.dzi').open('w') as fh: fh.write(self.get_dzi()) def get_dzi(self) -> str: @@ -253,8 +253,8 @@ class DeepZoomStaticTiler: def __init__( self, - slidepath: str, - basename: str, + slidepath: Path, + basename: Path, format: str, tile_size: int, overlap: int, @@ -303,12 +303,12 @@ def _run_image(self, associated: str | None = None) -> None: 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 ) @@ -335,9 +335,7 @@ def _write_html(self) -> None: # 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} @@ -357,22 +355,20 @@ def _write_html(self) -> None: 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) -> None: - basesrc = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') - basedst = os.path.join(self._basename, 'static') + 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: str, dest: str) -> None: - 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: str) -> str: @@ -444,6 +440,7 @@ def _shutdown(self) -> None: '-o', '--output', metavar='NAME', + type=Path, dest='basename', help='base name of output file', ) @@ -475,12 +472,13 @@ def _shutdown(self) -> None: 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, From 343d4356a3878c798fe70a279f1d6b220ac483cc Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 27 Oct 2024 14:31:01 -0700 Subject: [PATCH 108/137] Use explicit exception chaining when converting exception types This changes Python's exception chain reporting to make it clear that the new exception was not an unrelated failure while handling the original one, but rather the direct cause of it. Signed-off-by: Benjamin Gilbert --- openslide/__init__.py | 4 ++-- openslide/lowlevel.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openslide/__init__.py b/openslide/__init__.py index 60a32b41..39b83fa9 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -293,8 +293,8 @@ def set_cache(self, cache: OpenSlideCache) -> None: 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) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index c24ca7c9..7adb0dbe 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -84,18 +84,18 @@ def try_load(names: list[str]) -> CDLL: 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. " "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). @@ -107,17 +107,17 @@ def try_load(names: list[str]) -> CDLL: "Couldn't locate OpenSlide dylib. " "Try `pip install openslide-bin`. " "https://openslide.org/api/python/#installing" - ) + ) from exc return cdll.LoadLibrary(lib) else: try: return try_load(['libopenslide.so.1', 'libopenslide.so.0']) - except OSError: + 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() From d6fb5c09f0fc887ef1172e6cdefd9c7b9e58bf57 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 27 Oct 2024 14:39:02 -0700 Subject: [PATCH 109/137] Include all LoadLibrary() failures in exception chain If libopenslide.so.1 fails to load, we want to report that exception too, not just the fact that libopenslide.so.0 doesn't exist. Make try_load() recursive rather than iterative, allowing Python's exception chaining to handle this automatically. Reported-by: Govinda Kamath Signed-off-by: Benjamin Gilbert --- openslide/lowlevel.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 7adb0dbe..11ccc575 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -72,14 +72,16 @@ def _load_library() -> CDLL: pass def try_load(names: list[str]) -> CDLL: - for name in names: - try: - return cdll.LoadLibrary(name) - except OSError: - if name == names[-1]: - raise - else: - raise ValueError('No library names specified') + 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: From 803b4094db238b3ac7f82d0983f6c9a3910e6e4e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 27 Oct 2024 13:39:08 -0700 Subject: [PATCH 110/137] mypy: unconditionally ignore os.add_dll_directory() If OpenSlide Python is type-checked on Windows, os.add_dll_directory() will exist and mypy will complain. Add an extra ignore to fix this. Mypy understands conditionalizing on sys.platform but not on os.name, and we want the latter because it exactly corresponds to whether the function exists. Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_multiserver.py | 2 +- examples/deepzoom/deepzoom_server.py | 2 +- examples/deepzoom/deepzoom_tile.py | 2 +- tests/common.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 46f51743..8142b6fe 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -42,7 +42,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index f57579e9..fb9efbf1 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -42,7 +42,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 05e4d9a7..614b032c 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -48,7 +48,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide else: import openslide diff --git a/tests/common.py b/tests/common.py index 28bc8bfb..ca034195 100644 --- a/tests/common.py +++ b/tests/common.py @@ -30,7 +30,7 @@ # environment. _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 import openslide # noqa: F401 module-imported-but-unused From 72300fec838da313948c71a3ec7d1ec6873d0473 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 27 Oct 2024 13:21:19 -0700 Subject: [PATCH 111/137] examples/deepzoom: parallelize deepzoom_tile to available CPUs On Python 3.13+, and on all Python versions on Linux, set the default job count to the number of available CPUs. Otherwise, default to 4 jobs as before. Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_tile.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 614b032c..6f52324d 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -382,6 +382,19 @@ def _shutdown(self) -> None: 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', @@ -433,8 +446,8 @@ def _shutdown(self) -> None: 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', From f505a52a38bb52023587ba1f3b34c3ed33fe354f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 28 Oct 2024 11:20:21 -0700 Subject: [PATCH 112/137] CHANGELOG: add additional newlines between versions Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac7c22af..dfc8e194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * examples: Default to ICC profile's default intent, not absolute colorimetric * tests: Correctly require pytest ≥ 7.0 + ## Version 1.3.0, 2023-07-22 * Support new soname in OpenSlide ≥ 4.0.0 @@ -21,6 +22,7 @@ * examples: Correctly import `openslide` on Windows if `OPENSLIDE_PATH` not set * tests: Fix `pytest` of installed package from source directory + ## Version 1.2.0, 2022-06-17 * Drop support for Python \< 3.7 @@ -37,6 +39,7 @@ * examples: Read OpenSlide DLL path from `OPENSLIDE_PATH` env var on Windows * examples: Update OpenSeadragon to 3.0.0 + ## Version 1.1.2, 2020-09-13 * Fix install with setuptools ≥ 46 @@ -44,6 +47,7 @@ * Fix docs build with Sphinx 2.x * Remove `--without-performance` install option + ## Version 1.1.1, 2016-06-11 * Change default Deep Zoom tile size to 254 pixels @@ -54,6 +58,7 @@ * Add test suite * examples: Drop support for Internet Explorer \< 9 + ## Version 1.1.0, 2015-04-20 * Improve pixel read performance using optional extension module @@ -63,21 +68,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 @@ -93,6 +102,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 @@ -101,6 +111,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` @@ -110,6 +121,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 From 91ef77bae8ab0925ddce4a76e62e23f705e49363 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 29 Oct 2024 09:59:03 -0700 Subject: [PATCH 113/137] Update for release Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ openslide/_version.py | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc8e194..fcd632ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Notable Changes in OpenSlide Python +## Version 1.4.0, 2024-10-29 + +### New features + +* Support OpenSlide [installed from PyPI][] with `pip install openslide-bin` +* Add type hints for Python ≥ 3.10 +* Add wheels for Linux aarch64, Linux x86_64, and macOS arm64 + x86_64 +* Build version-independent wheels on Python ≥ 3.11 +* examples: Default `deepzoom_tile.py` job count to available CPUs when known + +### Changes + +* Drop wheel for 32-bit Windows +* Require `AbstractSlide` subclasses to implement all abstract methods +* Provide default `AbstractSlide.set_cache()` implementation +* Switch to [PEP 621][] project metadata +* docs: Document existence of `AbstractSlide` +* examples: Update OpenSeadragon to 5.0.0 + +### Bug fixes + +* If OpenSlide cannot be loaded, report errors from all attempts +* Fix `OpenSlide` support for `bytes` filename arguments (1.2.0 regression) +* Disallow arbitrary types as `OpenSlide` filename arguments (1.2.0 regression) +* Encode `OpenSlide` filename arguments using [Python filesystem encoding][] +* Add error check to `OpenSlide.set_cache()` +* docs: Fix types of properties that return tuples of items + +[installed from PyPI]: https://pypi.org/project/openslide-bin/ +[PEP 621]: https://peps.python.org/pep-0621/ +[Python filesystem encoding]: https://docs.python.org/3/glossary.html#term-filesystem-encoding-and-error-handler + + ## Version 1.3.1, 2023-10-08 * docs: Document using ICC profile's default intent, not absolute colorimetric diff --git a/openslide/_version.py b/openslide/_version.py index 2c184e31..ad0dab29 100644 --- a/openslide/_version.py +++ b/openslide/_version.py @@ -22,4 +22,4 @@ This module is an implementation detail. The package version should be obtained from openslide.__version__.""" -__version__ = '1.3.1' +__version__ = '1.4.0' From 7d0efd73df4f5ce4064e7d10fc0e1b8dab38c9ed Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 29 Oct 2024 12:01:17 -0700 Subject: [PATCH 114/137] setuptools 69.3+ normalizes sdist name to openslide_python Current versions of setuptools normalize the filename of the source tarball and of its toplevel directory to openslide_python-* per PEP 625. This is an intentional ecosystem-wide change, so adapt to it rather than trying to undo it. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- CHANGELOG.md | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 307b61ab..3adaf2eb 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -283,14 +283,14 @@ jobs: 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" + 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 + "openslide_python-$version/CHANGELOG.md" > changes gh release create --latest --verify-tag \ --repo "${{ github.repository }}" \ --title "OpenSlide Python $version" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd632ac..f348fce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Changes * Drop wheel for 32-bit Windows +* Rename source distribution to `openslide_python` per [PEP 625][] * Require `AbstractSlide` subclasses to implement all abstract methods * Provide default `AbstractSlide.set_cache()` implementation * Switch to [PEP 621][] project metadata @@ -29,6 +30,7 @@ * docs: Fix types of properties that return tuples of items [installed from PyPI]: https://pypi.org/project/openslide-bin/ +[PEP 625]: https://peps.python.org/pep-0625/ [PEP 621]: https://peps.python.org/pep-0621/ [Python filesystem encoding]: https://docs.python.org/3/glossary.html#term-filesystem-encoding-and-error-handler From d7c0f0ce64a8f568cb031608fe0e63a0ebdc31ff Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 29 Oct 2024 14:21:28 -0700 Subject: [PATCH 115/137] templates: release checklist updates The GitHub release will have multiple source tarballs. We should retile the demo site after a release because the OpenSlide Python version is listed on the site. EPEL packaging might skip some OpenSlide Python releases due to the EPEL update policy. Signed-off-by: Benjamin Gilbert --- .github/ISSUE_TEMPLATE/release.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 08ead1d9..c4d29a15 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -6,11 +6,12 @@ - [ ] 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, a 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 EPEL packages +- [ ] 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 - [ ] Post to [forum.image.sc](https://forum.image.sc/c/announcements/10) From 5994c37a87c6262f3827da92f64dbe2daddca554 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 29 Oct 2024 15:39:25 -0700 Subject: [PATCH 116/137] Fix configuration of package data When setuptools 61+ is configured via pyproject.toml, it defaults to include-package-data = true, rather than the historical default of false. We don't want wheels to include random files from the package directory, in particular _convert.c. Disable include-package-data. In addition, we were inadvertently relying on setuptools 69+ experimental functionality to include .pyi and py.typed files in the sdist and .pyi files in wheels. Explicitly configure this. Move py.typed package-data declaration from setup.py to pyproject.toml. Fixes: b893ba288c25 ("Switch to PEP 621 project metadata") Fixes: a40175e028b5 ("_convert: add type hints") Fixes: 5f49c0173cd8 ("Export type hints from openslide package") Signed-off-by: Benjamin Gilbert --- MANIFEST.in | 1 + pyproject.toml | 4 ++++ setup.py | 3 --- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 2aa1e1d6..6ac08ff1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include *.md +global-include 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/pyproject.toml b/pyproject.toml index 6bb05bb2..6394f6fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,11 +38,15 @@ Documentation = "https://openslide.org/api/python/" Repository = "https://github.com/openslide/openslide-python" [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 = ["py38", "py39", "py310", "py311", "py312", "py313"] diff --git a/setup.py b/setup.py index 4a5e532c..a4702e72 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,4 @@ # tag wheel for Limited API 'bdist_wheel': {'py_limited_api': 'cp311'} if _abi3 else {}, }, - package_data={ - 'openslide': ['py.typed'], - }, ) From c885d443d9d06d8f9a8e15cbc303c097f497ed22 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 29 Oct 2024 17:08:47 -0700 Subject: [PATCH 117/137] pre-commit: update versions Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d43bcbd..9affb182 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.18.0 + rev: v3.19.0 hooks: - id: pyupgrade name: Modernize python code @@ -35,7 +35,7 @@ repos: name: Format python code with black - repo: https://github.com/asottile/blacken-docs - rev: 1.19.0 + rev: 1.19.1 hooks: - id: blacken-docs name: Format python code in documentation @@ -54,7 +54,7 @@ repos: additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.12.0 + rev: v1.13.0 hooks: - id: mypy name: Check Python types From e3d30a49b7d5595f6be9acf6f860081289d2b60c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 30 Oct 2024 11:08:36 -0700 Subject: [PATCH 118/137] examples/deepzoom: Work around pathlib incompat in old Jinja2 FileSystemLoader in Jinja2 < 2.11.0 (including on Ubuntu 20.04) fails to wrap a single Path argument in a list because it thinks it's already a sequence. Work around this. Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_tile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 6f52324d..b637095b 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -335,7 +335,7 @@ def _write_html(self) -> None: # 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(Path(__file__).parent / '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} From 43a34553882f21cce59c46781c0e044d452b32ec Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 30 Oct 2024 10:44:46 -0700 Subject: [PATCH 119/137] Fix `setup.py install` with old setuptools We still need to support building distro packages with older setuptools that doesn't understand PEP 621. Re-add enough setup.py configuration (duplicating pyproject.toml) to make older setuptools happy. With setuptools >= 62.3.0, `setup.py install` will now warn about duplicate specification of dependencies, but the warning is harmless: SetuptoolsWarning: `install_requires` overwritten in `pyproject.toml` (dependencies) Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 19 +++++++++++++++++++ setup.py | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3adaf2eb..01274db6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -231,6 +231,25 @@ jobs: path: artifacts/whl compression-level: 0 + setuptools: + name: Setuptools install + needs: pre-commit + runs-on: ubuntu-20.04 + steps: + - name: Check out repo + uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libopenslide0 python3-pil + pip install pytest + - name: Install OpenSlide Python + run: sudo python setup.py install + - name: Run tests + run: pytest -v + - name: Tile slide + run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs + docs: name: Docs needs: pre-commit diff --git a/setup.py b/setup.py index a4702e72..68147386 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,12 @@ +from pathlib import Path import sys from setuptools import Extension, setup +# Load version string +with open(Path(__file__).parent / 'openslide/_version.py') as _fh: + exec(_fh.read()) # instantiates __version__ + # use the Limited API on Python 3.11+; build release-specific wheels on # older Python _abi3 = sys.version_info >= (3, 11) @@ -21,4 +26,18 @@ # 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', + ], + packages=[ + 'openslide', + ], + package_data={ + 'openslide': ['py.typed', '*.pyi'], + }, + zip_safe=False, ) From 52a281f64bf7b0b1ba4b61150bb1cb294177ee60 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 30 Oct 2024 12:42:46 -0700 Subject: [PATCH 120/137] Update for release Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 8 ++++++++ openslide/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f348fce6..18cd5dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Notable Changes in OpenSlide Python +## Version 1.4.1, 2024-10-30 + +### Bug fixes + +* Fix `setup.py install` with old setuptools (1.4.0 regression) +* examples: Fix `deepzoom_tile.py -r` with Jinja \< 2.11.0 (1.4.0 regression) + + ## Version 1.4.0, 2024-10-29 ### New features diff --git a/openslide/_version.py b/openslide/_version.py index ad0dab29..7e95329b 100644 --- a/openslide/_version.py +++ b/openslide/_version.py @@ -22,4 +22,4 @@ This module is an implementation detail. The package version should be obtained from openslide.__version__.""" -__version__ = '1.4.0' +__version__ = '1.4.1' From e2c870c7a98ecd4ce65d93b4e25e527f3e431a4e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 30 Oct 2024 16:13:42 -0700 Subject: [PATCH 121/137] workflows: omit *.publish.attestation files from release artifacts gh-action-pypi-publish now writes attestation files to the artifact directory that should not be uploaded to the GitHub release. Add explicit wildcards for the file extensions we do want to upload. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 01274db6..96ffb069 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -310,9 +310,11 @@ jobs: -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 }}/"* + "${{ needs.pre-commit.outputs.dist-base }}/"*.{tar.gz,tar.xz,whl} From dd707709d15de5aa496b197e5b96d8b4f7b72c39 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 23 Dec 2024 23:04:24 -0800 Subject: [PATCH 122/137] examples/deepzoom: update OpenSeadragon to 5.0.1 Signed-off-by: Benjamin Gilbert --- examples/deepzoom/static/openseadragon.js | 106 ++++++++++++++-------- 1 file changed, 66 insertions(+), 40 deletions(-) diff --git a/examples/deepzoom/static/openseadragon.js b/examples/deepzoom/static/openseadragon.js index dec64da0..8b887076 100644 --- a/examples/deepzoom/static/openseadragon.js +++ b/examples/deepzoom/static/openseadragon.js @@ -1,6 +1,6 @@ -//! openseadragon 5.0.0 -//! Built on 2024-08-14 -//! Git commit: v5.0.0-0-f28b7fc1 +//! 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/ @@ -90,7 +90,7 @@ /** * @namespace OpenSeadragon - * @version openseadragon 5.0.0 + * @version openseadragon 5.0.1 * @classdesc The root namespace for OpenSeadragon. All utility methods * and classes are defined on or below this namespace. * @@ -220,7 +220,7 @@ * 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 the canvas drawer 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.DrawerBase.setImageSmoothingEnabled}. * @@ -856,10 +856,10 @@ function OpenSeadragon( options ){ * @since 1.0.0 */ $.version = { - versionStr: '5.0.0', + versionStr: '5.0.1', major: parseInt('5', 10), minor: parseInt('0', 10), - revision: parseInt('0', 10) + revision: parseInt('1', 10) }; @@ -10407,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() { @@ -10416,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(); } }, @@ -12289,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); }, @@ -19044,9 +19041,17 @@ $.Tile.prototype = { }; } + this.elementWrapper = document.createElement('div'); this.element = options.element; - this.element.innerHTML = "
" + this.element.innerHTML + "
"; - 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); }; @@ -19113,7 +19118,7 @@ $.Tile.prototype = { * @function */ destroy: function() { - var element = this.element; + var element = this.elementWrapper; var style = this.style; if (element.parentNode) { @@ -19158,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; @@ -19169,7 +19174,7 @@ $.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; @@ -19186,15 +19191,15 @@ $.Tile.prototype = { this.onDraw(position, size, this.element); } else { var style = this.style; - var innerElement = element.firstChild; - var innerStyle = innerElement.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'); @@ -19219,7 +19224,7 @@ $.Tile.prototype = { style[transformProp] = ""; } } - style.display = 'block'; + style.display = 'flex'; } }, @@ -19271,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; } @@ -21155,6 +21160,8 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { 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); @@ -21198,10 +21205,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { gl.bindRenderbuffer(gl.RENDERBUFFER, null); gl.bindFramebuffer(gl.FRAMEBUFFER, null); - let canvases = Array.from(this._TextureMap.keys()); - canvases.forEach(canvas => { - this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap - }); + this._unloadTextures(); // Delete all our created resources gl.deleteBuffer(this._secondPass.bufferOutputPosition); @@ -21223,6 +21227,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { // 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; @@ -21317,9 +21322,10 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { */ draw(tiledImages){ let gl = this._gl; + const bounds = this.viewport.getBoundsNoRotateWithMargins(true); let view = { - bounds: this.viewport.getBoundsNoRotate(true), - center: this.viewport.getCenter(true), + bounds: bounds, + center: new OpenSeadragon.Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2), rotation: this.viewport.getRotation(true) * Math.PI / 180 }; @@ -21511,7 +21517,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { 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._firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer(this._secondPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); // Draw the quad (two triangles) gl.drawArrays(gl.TRIANGLES, 0, 6); @@ -21547,11 +21553,15 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { // Public API required by all Drawer implementations /** - * Required by DrawerBase, but has no effect on WebGLDrawer. - * @param {Boolean} enabled + * 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){ - // noop - this property does not impact WebGLDrawer + if( this._imageSmoothingEnabled !== enabled ){ + this._imageSmoothingEnabled = enabled; + this._unloadTextures(); + this.viewer.world.draw(); + } } /** @@ -21661,6 +21671,11 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { } + // private + _textureFilter(){ + return this._imageSmoothingEnabled ? this._gl.LINEAR : this._gl.NEAREST; + } + // private _setupRenderer(){ let gl = this._gl; @@ -21677,7 +21692,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { 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, gl.LINEAR); + 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); @@ -21876,7 +21891,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { 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, gl.LINEAR); + 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); @@ -21902,8 +21917,7 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { this._gl = this._renderingCanvas.getContext('webgl'); - //make the additional canvas elements mirror size changes to the output canvas - this.viewer.addHandler("resize", function(){ + this._resizeHandler = function(){ if(_this._outputCanvas !== _this.viewer.drawer.canvas){ _this._outputCanvas.style.width = _this.viewer.drawer.canvas.clientWidth + 'px'; @@ -21924,7 +21938,10 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { // 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); } // private @@ -22014,8 +22031,8 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { // 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, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + 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); @@ -22039,6 +22056,14 @@ function determineSubPixelRoundingRule(subPixelRoundingRules) { }; } + // private + _unloadTextures(){ + let canvases = Array.from(this._TextureMap.keys()); + canvases.forEach(canvas => { + this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap + }); + } + // private _uploadImageData(tileContext){ @@ -25101,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. From 05f29703eb194bf6500d8808299e74732a8f7c8d Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Jan 2025 15:24:51 -0800 Subject: [PATCH 123/137] workflows: switch setuptools job to Ubuntu 20.04 container GitHub is deprecating the ubuntu-20.04 image: https://github.blog/changelog/2025-01-15-github-actions-ubuntu-20-runner-image-brownout-dates-and-other-breaking-changes/ Move repo checkout after dependency installation so it can use Git. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 96ffb069..f0999253 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -234,21 +234,23 @@ jobs: setuptools: name: Setuptools install needs: pre-commit - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + container: ubuntu:20.04 steps: - - name: Check out repo - uses: actions/checkout@v4 - name: Install dependencies run: | - sudo apt-get update - sudo apt-get install -y libopenslide0 python3-pil + 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: sudo python setup.py install + run: python3 setup.py install - name: Run tests run: pytest -v - name: Tile slide - run: python examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs + run: python3 examples/deepzoom/deepzoom_tile.py --viewer -o tiled tests/fixtures/small.svs docs: name: Docs From 36ebf82764cb4f32dca790befe3636404f9291ec Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 16 Jan 2025 09:13:52 -0800 Subject: [PATCH 124/137] workflows: switch ARM jobs to publicly-available runners Drop the paid runners. https://github.blog/changelog/2025-01-16-linux-arm64-hosted-runners-now-available-for-free-in-public-repositories-public-preview/ Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f0999253..b54d3560 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, ubuntu-24.04-aarch64, macos-latest] + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] openslide: [system, wheel] include: @@ -96,7 +96,7 @@ jobs: echo OS_ARCH_TAG=linux-x86_64 >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; - ubuntu-24.04-aarch64) + ubuntu-24.04-arm) echo OS_ARCH_TAG=linux-aarch64 >> $GITHUB_ENV sudo apt-get install libopenslide0 ;; From f9af6e8fead6b14a33b47227913a943700f84963 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 14 Apr 2025 12:00:10 -0500 Subject: [PATCH 125/137] pre-commit: add codespell Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 8 ++++++++ pyproject.toml | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9affb182..457d20b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,6 +67,14 @@ repos: name: Validate reStructuredText syntax 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 diff --git a/pyproject.toml b/pyproject.toml index 6394f6fd..bb8eaea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,12 @@ openslide = ["py.typed", "*.pyi"] skip-string-normalization = true target-version = ["py38", "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 From 3e48604c2c3b0721ac29b6e6926b319dd26f28a7 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 14 Apr 2025 13:17:31 -0500 Subject: [PATCH 126/137] pre-commit: update versions _Func.__call__() correctly does not have a **kwargs argument. Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 10 +++++----- openslide/deepzoom.py | 4 ++-- openslide/lowlevel.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 457d20b1..633bcbf7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,20 +16,20 @@ repos: - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade name: Modernize python code args: ["--py38-plus"] - repo: https://github.com/PyCQA/isort - rev: 5.13.2 + rev: 6.0.1 hooks: - id: isort name: Reorder python imports with isort - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black name: Format python code with black @@ -47,14 +47,14 @@ repos: additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.2.0 hooks: - id: flake8 name: Lint python code with flake8 additional_dependencies: [flake8-bugbear, Flake8-pyproject] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.15.0 hooks: - id: mypy name: Check Python types diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 9d0b3ab8..e712b0e6 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -263,10 +263,10 @@ def _z_from_t(self, t: int) -> int: @staticmethod def _pairs_from_n_tuples( - tuples: tuple[tuple[int, ...], ...] + tuples: tuple[tuple[int, ...], ...], ) -> tuple[tuple[int, int], ...]: def all_pairs( - tuples: tuple[tuple[int, ...], ...] + tuples: tuple[tuple[int, ...], ...], ) -> TypeGuard[tuple[tuple[int, int], ...]]: return all(len(t) == 2 for t in tuples) diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 11ccc575..9aab42b8 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -322,7 +322,7 @@ def __call__(self, *_args: Any) -> Any: class _Func(Protocol[_P, _T]): available: bool - def __call__(self, *args: _P.args) -> _T: ... + def __call__(self, *args: _P.args) -> _T: ... # type: ignore[valid-type] class _CTypesFunc(_Func[_P, _T]): restype: type | None @@ -358,7 +358,7 @@ def _func( def _wraps_funcs( - wrapped: list[_Func[..., Any]] + wrapped: list[_Func[..., Any]], ) -> Callable[[Callable[_P, _T]], _Func[_P, _T]]: def decorator(fn: Callable[_P, _T]) -> _Func[_P, _T]: if TYPE_CHECKING: From 775f52a632faaa45fafa98a7d4660874d3fef68b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 22 Apr 2025 01:51:07 -0500 Subject: [PATCH 127/137] workflows: switch setuptools job to Ubuntu 22.04 container Ubuntu 20.04 is exiting standard security support at the end of May. Switch to 22.04, which still has an old enough setuptools. Stick with the container, rather than using GitHub's ubuntu-22.04 runner image, for better control over the included software versions. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b54d3560..d463e80f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -235,7 +235,7 @@ jobs: name: Setuptools install needs: pre-commit runs-on: ubuntu-latest - container: ubuntu:20.04 + container: ubuntu:22.04 steps: - name: Install dependencies run: | From e97555ba1f9993c71d1ffbfc30fd1dad4c02fa47 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 24 Apr 2025 17:08:43 -0500 Subject: [PATCH 128/137] workflows: expect macOS universal binaries from setup-python on 3.{8,9,10} It appears setup-python's Python 3.8, 3.9, and 3.10 macOS builds are now capable of producing universal binaries. Stop using the upstream Python 3.9 and 3.10 builds and remove the test exception for 3.8. The change seems to have happened before we released any macOS wheels, so this won't affect the 3.8 release artifacts. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d463e80f..3ecadf08 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -50,16 +50,6 @@ jobs: python-version: "3.13" openslide: system sdist: sdist - # Python 3.8 is too old to support universal binaries, and - # setup-python's Python 3.9 and 3.10 won't build them. Use the - # last upstream patch releases that ship with installers. - # https://github.com/actions/setup-python/issues/439#issuecomment-1247646682 - - os: macos-latest - python-version: "3.9" - upstream-python: 3.9.13 - - os: macos-latest - python-version: "3.10" - upstream-python: 3.10.11 steps: - name: Check out repo uses: actions/checkout@v4 @@ -122,7 +112,7 @@ jobs: auditwheel repair --only-plat -w dist old/*whl ;; macos-*) - if [ "${{ matrix.python-version }}" != 3.8 -a ! -e dist/*universal2* ]; then + if [ ! -e dist/*universal2* ]; then echo "Wheel is not universal:" ls dist exit 1 From a9dbe638a1cd10ff1ddd9b5e13678b74e7714767 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 24 Apr 2025 19:59:06 -0500 Subject: [PATCH 129/137] examples/deepzoom: add license files for bundled JS The licenses are included or linked at the top of the JS files, but it's better to also ship them separately. Also document these licenses in the README. Signed-off-by: Benjamin Gilbert --- MANIFEST.in | 2 +- README.md | 8 +++++- examples/deepzoom/licenses/LICENSE.jquery | 20 +++++++++++++ .../deepzoom/licenses/LICENSE.openseadragon | 28 +++++++++++++++++++ .../licenses/LICENSE.openseadragon-scalebar | 9 ++++++ 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 examples/deepzoom/licenses/LICENSE.jquery create mode 100644 examples/deepzoom/licenses/LICENSE.openseadragon create mode 100644 examples/deepzoom/licenses/LICENSE.openseadragon-scalebar diff --git a/MANIFEST.in b/MANIFEST.in index 6ac08ff1..ea26c3f7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include *.md -global-include py.typed *.pyi +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 b1ec2667..a675dead 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,13 @@ installing so OpenSlide Python can find OpenSlide. ## 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/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. From 50dd399b0248ff708e00310a5a283c1c521ce59b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 24 Apr 2025 14:33:54 -0500 Subject: [PATCH 130/137] Drop support for Python 3.8 It's EOL. Ubuntu 20.04 is the last officially-supported distro that uses it, and Ubuntu 20.04 will exit standard security maintenance on May 31. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 4 ++-- .pre-commit-config.yaml | 2 +- README.md | 2 +- examples/deepzoom/deepzoom_server.py | 4 ++-- openslide/__init__.py | 3 ++- pyproject.toml | 5 ++--- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3ecadf08..6c60d9f7 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -43,7 +43,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] openslide: [system, wheel] include: - os: ubuntu-latest @@ -158,7 +158,7 @@ jobs: shell: bash strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] openslide: [zip, wheel] steps: - name: Check out repo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 633bcbf7..e01c82ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: pyupgrade name: Modernize python code - args: ["--py38-plus"] + args: ["--py39-plus"] - repo: https://github.com/PyCQA/isort rev: 6.0.1 diff --git a/README.md b/README.md index a675dead..50f426d9 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ OpenSlide can read virtual slides in several formats: ## Requirements -* Python ≥ 3.8 +* Python ≥ 3.9 * OpenSlide ≥ 3.4.0 * Pillow diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index fb9efbf1..cc051cad 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -23,12 +23,12 @@ from argparse import ArgumentParser import base64 -from collections.abc import Callable +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, Mapping +from typing import TYPE_CHECKING, Any, Literal from unicodedata import normalize import zlib diff --git a/openslide/__init__.py b/openslide/__init__.py index 39b83fa9..1db83c2e 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -26,9 +26,10 @@ 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 Iterator, Literal, Mapping, TypeVar +from typing import Literal, TypeVar from PIL import Image, ImageCms diff --git a/pyproject.toml b/pyproject.toml index bb8eaea9..4969715b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ "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", @@ -27,7 +26,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Bio-Informatics", "Typing :: Typed", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" dependencies = ["Pillow"] dynamic = ["version"] @@ -49,7 +48,7 @@ openslide = ["py.typed", "*.pyi"] [tool.black] skip-string-normalization = true -target-version = ["py38", "py39", "py310", "py311", "py312", "py313"] +target-version = ["py39", "py310", "py311", "py312", "py313"] # Ref: https://github.com/codespell-project/codespell#using-a-config-file [tool.codespell] From a107b17958c12fa77e72f74d4ac020bd5469abe3 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 26 Apr 2025 06:24:28 -0500 Subject: [PATCH 131/137] Sync LGPLv2.1 text with FSF; remove FSF mailing address Signed-off-by: Benjamin Gilbert --- COPYING.LESSER | 9 ++++----- doc/jekyll_fix.py | 3 +-- examples/deepzoom/deepzoom_multiserver.py | 3 +-- examples/deepzoom/deepzoom_server.py | 3 +-- examples/deepzoom/deepzoom_tile.py | 3 +-- openslide/__init__.py | 3 +-- openslide/_convert.c | 3 +-- openslide/_convert.pyi | 3 +-- openslide/_version.py | 3 +-- openslide/deepzoom.py | 3 +-- openslide/lowlevel.py | 3 +-- tests/common.py | 3 +-- tests/test_base.py | 3 +-- tests/test_deepzoom.py | 3 +-- tests/test_imageslide.py | 3 +-- tests/test_openslide.py | 3 +-- 16 files changed, 19 insertions(+), 35 deletions(-) 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/doc/jekyll_fix.py b/doc/jekyll_fix.py index ef6555c5..d7fe8bf2 100644 --- a/doc/jekyll_fix.py +++ b/doc/jekyll_fix.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 . # # Sphinx hardcodes that certain output paths have names starting with diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 8142b6fe..0f43680e 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -15,8 +15,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 . # from __future__ import annotations diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index fb9efbf1..9b30f207 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -15,8 +15,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 . # from __future__ import annotations diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index b637095b..65b75b3e 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -15,8 +15,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 . # """An example program to generate a Deep Zoom directory tree from a slide.""" diff --git a/openslide/__init__.py b/openslide/__init__.py index 39b83fa9..d743780b 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. diff --git a/openslide/_convert.c b/openslide/_convert.c index 6b0bf838..37d6e75a 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 diff --git a/openslide/_convert.pyi b/openslide/_convert.pyi index c68c9781..03bf743b 100644 --- a/openslide/_convert.pyi +++ b/openslide/_convert.pyi @@ -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 . # from typing import Protocol diff --git a/openslide/_version.py b/openslide/_version.py index 7e95329b..9d37969a 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. diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index e712b0e6..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. diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 9aab42b8..6608033d 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.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 . # """ diff --git a/tests/common.py b/tests/common.py index ca034195..aaa86f7f 100644 --- a/tests/common.py +++ b/tests/common.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 . # from __future__ import annotations diff --git a/tests/test_base.py b/tests/test_base.py index 2ed9116e..9b3b3867 100644 --- a/tests/test_base.py +++ b/tests/test_base.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 . # from __future__ import annotations diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index 1c67ab06..33b98688 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_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 . # from __future__ import annotations diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index 051263ee..dd3fe6f9 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.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 . # from __future__ import annotations diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 2be656fd..26077efe 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.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 . # from __future__ import annotations From ad386c884d267b44785e256c181cf45dc74ac05d Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 26 Apr 2025 05:39:59 -0500 Subject: [PATCH 132/137] pyproject: add dependency group for test deps Add a PEP 735 dependency group that installs a supported version of pytest. This allows downstream build systems (e.g. Fedora) to autodetect test dependencies. pip 25.1 allows installing these dependencies with `pip install --group test`. Signed-off-by: Benjamin Gilbert --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4969715b..fe649f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ 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"] From 43049b6e5dc5d567472247ff665c53936c5e331c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Fri, 25 Apr 2025 23:44:20 -0500 Subject: [PATCH 133/137] pyproject: switch to PEP 639 license metadata Conform with PEP 639 by adding links to our license files, using a SPDX license expression to declare our licenses, and dropping the deprecated Trove license classifier. Without this change, setuptools shows a large deprecation warning saying that support for the old project.license syntax will be removed on 2026-Feb-18. However, the new syntax requires setuptools >= 77, so this change breaks compatibility with setuptools 61-76. Users installing from source will be unaffected because the build frontend automatically updates setuptools if required, but Linux distro packaging disallows such updates, so this change will break package builds for many still-supported distro releases. To build with setuptools 61 through 76, distros will need to patch out the project.license field and reduce the build-system.requires dependency. Signed-off-by: Benjamin Gilbert --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe649f64..a76799db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,14 @@ maintainers = [ ] description = "Python interface to OpenSlide" readme = "README.md" -license = {text = "GNU Lesser General Public License, version 2.1"} +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", - "License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", @@ -87,5 +87,5 @@ pythonpath = "tests" ignore_messages = "(Hyperlink target \".*\" is not referenced\\.$)" [build-system] -requires = ["setuptools >= 61.0.0"] +requires = ["setuptools >= 77.0.0"] build-backend = "setuptools.build_meta" From fe11899da72b72264ebc81707e14e0bd6ca9d89d Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 28 Apr 2025 21:57:16 -0500 Subject: [PATCH 134/137] OpenSlide Python 1.4.2 Signed-off-by: Benjamin Gilbert --- CHANGELOG.md | 14 ++++++++++++++ openslide/_version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cd5dd1..fae3f137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Notable Changes in OpenSlide Python +## Version 1.4.2, 2025-04-28 + +### Changes + +* Drop support for Python 3.8 +* Switch to [PEP 639][] project license metadata +* examples: Update OpenSeadragon to 5.0.1 +* examples: Add license files for bundled JavaScript +* tests: Add [PEP 735][] dependency group for test dependencies + +[PEP 639]: https://peps.python.org/pep-0639/ +[PEP 735]: https://peps.python.org/pep-0735/ + + ## Version 1.4.1, 2024-10-30 ### Bug fixes diff --git a/openslide/_version.py b/openslide/_version.py index 9d37969a..21324249 100644 --- a/openslide/_version.py +++ b/openslide/_version.py @@ -21,4 +21,4 @@ This module is an implementation detail. The package version should be obtained from openslide.__version__.""" -__version__ = '1.4.1' +__version__ = '1.4.2' From 0501e5f16fe258851a4ac8bf42c271b5d021e634 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 29 Jun 2025 13:41:12 -0700 Subject: [PATCH 135/137] convert: name structs consistently Signed-off-by: Benjamin Gilbert --- openslide/_convert.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openslide/_convert.c b/openslide/_convert.c index 37d6e75a..efec39c8 100644 --- a/openslide/_convert.c +++ b/openslide/_convert.c @@ -96,22 +96,22 @@ _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 struct PyModuleDef _convert_module = { PyModuleDef_HEAD_INIT, "_convert", NULL, 0, - ConvertMethods + _convert_methods, }; PyMODINIT_FUNC PyInit__convert(void) { - return PyModule_Create2(&convertmodule, PYTHON_API_VERSION); + return PyModule_Create2(&_convert_module, PYTHON_API_VERSION); } From 442f91ba0b30a2682711be7235efe6d2f64b26d9 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 29 Jun 2025 13:47:42 -0700 Subject: [PATCH 136/137] convert: switch to multi-phase module initialization Single-phase initialization is considered legacy. Signed-off-by: Benjamin Gilbert --- openslide/_convert.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openslide/_convert.c b/openslide/_convert.c index efec39c8..b9422f07 100644 --- a/openslide/_convert.c +++ b/openslide/_convert.c @@ -102,16 +102,21 @@ static PyMethodDef _convert_methods[] = { {NULL, NULL, 0, NULL} }; +static PyModuleDef_Slot _convert_slots[] = { + {0, NULL} +}; + static struct PyModuleDef _convert_module = { PyModuleDef_HEAD_INIT, "_convert", NULL, 0, _convert_methods, + _convert_slots, }; PyMODINIT_FUNC PyInit__convert(void) { - return PyModule_Create2(&_convert_module, PYTHON_API_VERSION); + return PyModuleDef_Init(&_convert_module); } From 174814ad74762dce1790d4a700ca164b08bf0f1e Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sat, 19 Jul 2025 18:06:41 -0700 Subject: [PATCH 137/137] workflows: add tests on Python 3.14 dev release Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6c60d9f7..64887e34 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -43,7 +43,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] - python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14-dev"] openslide: [system, wheel] include: - os: ubuntu-latest @@ -158,7 +158,7 @@ jobs: shell: bash strategy: matrix: - python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14-dev"] openslide: [zip, wheel] steps: - name: Check out repo