From cf3bd30cd205171579b65b63577969936f18d5c1 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 18 Jun 2026 17:40:31 -0500 Subject: [PATCH] Fix IIIF image viewer rendering --- .../leaflet_viewer_controller.test.js | 57 ++++++++++++++++++- .../src/geoblacklight/iiif_image_layer.js | 13 ++++- .../leaflet_viewer_controller.js | 13 +++-- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js b/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js index 9795e6f7..508cff88 100644 --- a/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js +++ b/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js @@ -6,7 +6,9 @@ import { getIiifLeafletMapOptions, getIiifMaxNativeZoom, getIiifTileUrl, + IIIF_MIN_ZOOM, normalizeIiifImageServiceId, + resizeIiifTileToNaturalSize, } from '../../geoblacklight/iiif_image_layer'; const baseTileOptions = { @@ -56,11 +58,28 @@ describe('Leaflet IIIF helpers', () => { expect(options.gestureHandling).toBe(true); expect(options.maxBounds.contains(bounds)).toBe(true); expect(options.maxZoom).toBe(3); - expect(options.minZoom).toBe(0); + expect(options.minZoom).toBe(IIIF_MIN_ZOOM); expect(options.scrollWheelZoom).toBe(true); expect(options.zoom).toBe(0); }); + it('allows large CONTENTdm scans to fit by zooming out below native IIIF zoom 0', () => { + const maxNativeZoom = getIiifMaxNativeZoom(8826, 11246, 1024); + const bounds = getIiifImageBounds(Leaflet, 8826, 11246, maxNativeZoom); + const options = getIiifLeafletMapOptions( + Leaflet, + bounds, + maxNativeZoom, + { SLEEP: false }, + {} + ); + + expect(maxNativeZoom).toBe(4); + expect(bounds.getNorthEast().lng).toBeCloseTo(551.625); + expect(bounds.getSouthWest().lat).toBeCloseTo(-702.875); + expect(options.minZoom).toBe(-5); + }); + it('normalizes IIIF service ids from info.json metadata', () => { expect( normalizeIiifImageServiceId('https://example.com/fallback/info.json', { @@ -80,6 +99,20 @@ describe('Leaflet IIIF helpers', () => { ); }); + it('clamps display zooms below the native IIIF pyramid to zoom 0 tile requests', () => { + expect( + getIiifTileUrl({ + ...baseTileOptions, + coords: { x: 0, y: 0, z: -2 }, + }) + ).toBe( + getIiifTileUrl({ + ...baseTileOptions, + coords: { x: 0, y: 0, z: 0 }, + }) + ); + }); + it('clips IIIF edge tiles to the image dimensions', () => { expect( getIiifTileUrl({ @@ -111,4 +144,26 @@ describe('Leaflet IIIF helpers', () => { }) ).toBe(EMPTY_IIIF_TILE_URL); }); + + it('resizes non-square IIIF tiles to their natural image dimensions', () => { + const tile = document.createElement('img'); + Object.defineProperty(tile, 'naturalWidth', { value: 552 }); + Object.defineProperty(tile, 'naturalHeight', { value: 703 }); + + resizeIiifTileToNaturalSize(tile, 1024); + + expect(tile.style.width).toBe('552px'); + expect(tile.style.height).toBe('703px'); + }); + + it('leaves full-size square IIIF tiles alone', () => { + const tile = document.createElement('img'); + Object.defineProperty(tile, 'naturalWidth', { value: 1024 }); + Object.defineProperty(tile, 'naturalHeight', { value: 1024 }); + + resizeIiifTileToNaturalSize(tile, 1024); + + expect(tile.style.width).toBe(''); + expect(tile.style.height).toBe(''); + }); }); diff --git a/frontend/src/geoblacklight/iiif_image_layer.js b/frontend/src/geoblacklight/iiif_image_layer.js index f02d0062..65031d0b 100644 --- a/frontend/src/geoblacklight/iiif_image_layer.js +++ b/frontend/src/geoblacklight/iiif_image_layer.js @@ -1,5 +1,6 @@ export const EMPTY_IIIF_TILE_URL = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; +export const IIIF_MIN_ZOOM = -5; function getInfoId(info) { const id = info.id ?? info['@id']; @@ -111,6 +112,16 @@ export function getIiifTileUrl({ return `${baseUrl}/${region}/${size}/0/${tileQuality}.${tileFormat}`; } +export function resizeIiifTileToNaturalSize(tile, tileSize) { + const { naturalHeight, naturalWidth } = tile; + + if (!naturalHeight || !naturalWidth) return; + if (naturalHeight === tileSize && naturalWidth === tileSize) return; + + tile.style.width = `${naturalWidth}px`; + tile.style.height = `${naturalHeight}px`; +} + export async function fetchIiifImageInfo(imageServiceOrInfoUrl) { const base = imageServiceOrInfoUrl.replace(/\/$/, ''); const infoUrl = base.endsWith('/info.json') ? base : `${base}/info.json`; @@ -143,7 +154,7 @@ export function getIiifLeafletMapOptions( maxBounds: imageBounds.pad(0.5), maxBoundsViscosity: 0.5, maxZoom: maxNativeZoom, - minZoom: 0, + minZoom: IIIF_MIN_ZOOM, preferCanvas: false, zoom: 0, }; diff --git a/frontend/src/geoblacklight/leaflet_viewer_controller.js b/frontend/src/geoblacklight/leaflet_viewer_controller.js index 37e2cb82..6bbf6313 100644 --- a/frontend/src/geoblacklight/leaflet_viewer_controller.js +++ b/frontend/src/geoblacklight/leaflet_viewer_controller.js @@ -12,7 +12,9 @@ import { getIiifTileFormat, getIiifTileSize, getIiifTileUrl, + IIIF_MIN_ZOOM, normalizeIiifImageServiceId, + resizeIiifTileToNaturalSize, } from './iiif_image_layer'; function buildIiifTileLayer(options) { @@ -38,6 +40,9 @@ function buildIiifTileLayer(options) { updateWhenIdle: true, }); layer.maxNativeZoom = options.maxNativeZoom || 0; + layer.on('tileload', ({ tile }) => { + resizeIiifTileToNaturalSize(tile, options.tileSize); + }); return layer; } @@ -157,7 +162,8 @@ export default class LeafletViewerController extends BaseLeafletViewerController imageWidth: width, maxNativeZoom, maxZoom: maxNativeZoom, - minZoom: 0, + minNativeZoom: 0, + minZoom: IIIF_MIN_ZOOM, serviceId: normalizeIiifImageServiceId(this.urlValue, info), tileFormat: getIiifTileFormat(info), tileQuality: 'default', @@ -214,11 +220,6 @@ export default class LeafletViewerController extends BaseLeafletViewerController } addIiifControls() { - if (this.availableValue && this.previewOverlay) { - const opacityControl = this.getControl('Opacity'); - if (opacityControl) this.addControl(opacityControl); - } - const fullscreenControl = this.getControl('Fullscreen'); if (fullscreenControl) this.addControl(fullscreenControl); }