triiiceratops 0.16.9 → 0.16.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
  MOBILE_DRAWER_FALLBACK,
8
8
  shouldUseMobileDrawerFallback,
9
9
  } from './osdDefaults';
10
+ import { resolveTileSources } from './osdTileSources';
10
11
  import { parseAnnotations } from '../utils/annotationAdapter';
11
12
  import { manifestsState } from '../state/manifests.svelte';
12
13
  import type { ViewerState } from '../state/viewer.svelte';
@@ -21,9 +22,6 @@
21
22
  let viewer: any | undefined = $state.raw();
22
23
  let OSD: any | undefined = $state();
23
24
 
24
- const IIIF_LEVEL0_V2_HTTPS = 'https://iiif.io/api/image/2/level0.json';
25
- const IIIF_LEVEL0_V2_HTTP = 'http://iiif.io/api/image/2/level0.json';
26
-
27
25
  // Track OSD state changes for reactivity
28
26
  let osdVersion = $state(0);
29
27
  // Track last opened tile source to prevent unnecessary resets
@@ -178,7 +176,6 @@
178
176
  if (!mounted) return;
179
177
 
180
178
  OSD = osdModule.default || osdModule;
181
- patchIiifLevel0ProfileCompatibility(OSD);
182
179
  const userAgent = navigator.userAgent || '';
183
180
  const consumerOverrides = viewerState.config?.openSeadragonConfig ?? {};
184
181
 
@@ -220,8 +217,8 @@
220
217
  const overrides = viewerState.config?.openSeadragonConfig ?? {};
221
218
  if (overrides.minZoomLevel !== undefined) return;
222
219
 
223
- // Prevent zooming into the "no tiles rendered" range seen on
224
- // some level-0 services when zoomed far past home view.
220
+ // Keep a conservative floor below home zoom to avoid over-zoomed
221
+ // empty/unstable ranges while preserving normal navigation.
225
222
  const homeZoom = viewer.viewport.getHomeZoom();
226
223
  const floorFactor =
227
224
  viewerState.viewingMode === 'continuous' ? 0.8 : 0.95;
@@ -278,132 +275,6 @@
278
275
  }
279
276
  });
280
277
 
281
- function normalizeIiifLevel0Profile<T>(source: T): T {
282
- if (!source || typeof source !== 'object') return source;
283
-
284
- const obj = source as any;
285
- const profile = obj.profile;
286
- const getProfileHead = (p: any): string | null => {
287
- if (typeof p === 'string') return p;
288
- if (Array.isArray(p) && typeof p[0] === 'string') return p[0];
289
- return null;
290
- };
291
- const profileHead = getProfileHead(profile);
292
- const isLevel0 =
293
- profileHead === 'level0' ||
294
- profileHead === IIIF_LEVEL0_V2_HTTPS ||
295
- profileHead === IIIF_LEVEL0_V2_HTTP ||
296
- (typeof profileHead === 'string' &&
297
- (profileHead.endsWith('/level0.json') ||
298
- profileHead.endsWith('#level0')));
299
-
300
- // Keep this minimal and conservative: OSD 5 misses the https v2 profile
301
- // variant in some paths, so normalize only that string form.
302
- if (typeof profile === 'string' && profile === IIIF_LEVEL0_V2_HTTPS) {
303
- obj.profile = IIIF_LEVEL0_V2_HTTP;
304
- } else if (Array.isArray(profile) && profile.length > 0) {
305
- const first = profile[0];
306
- if (typeof first === 'string' && first === IIIF_LEVEL0_V2_HTTPS) {
307
- obj.profile = [IIIF_LEVEL0_V2_HTTP, ...profile.slice(1)];
308
- }
309
- }
310
-
311
- // Some level-0 services advertise `tiles`, but 1x1 tile levels produce
312
- // `/full/w,h/...` requests that 404. Keep only scale factors that still
313
- // require at least 2 tiles in one dimension.
314
- if (
315
- isLevel0 &&
316
- Array.isArray(obj.tiles) &&
317
- obj.tiles.length > 0 &&
318
- typeof obj.width === 'number' &&
319
- typeof obj.height === 'number'
320
- ) {
321
- obj.tiles = obj.tiles.map((tile: any) => {
322
- if (!Array.isArray(tile?.scaleFactors)) return tile;
323
- const tileW =
324
- typeof tile.width === 'number' ? tile.width : 0;
325
- const tileH =
326
- typeof tile.height === 'number' ? tile.height : tileW;
327
- if (!tileW || !tileH) return tile;
328
-
329
- const filtered = tile.scaleFactors.filter((sf: any) => {
330
- if (typeof sf !== 'number' || sf <= 0) return false;
331
- const levelW = Math.ceil(obj.width / sf);
332
- const levelH = Math.ceil(obj.height / sf);
333
- const tilesX = Math.ceil(levelW / tileW);
334
- const tilesY = Math.ceil(levelH / tileH);
335
- return tilesX > 1 || tilesY > 1;
336
- });
337
-
338
- // If everything is filtered out, keep original to avoid breaking source.
339
- if (!filtered.length) return tile;
340
- return { ...tile, scaleFactors: filtered };
341
- });
342
- }
343
-
344
- return source;
345
- }
346
-
347
- function patchIiifLevel0ProfileCompatibility(osd: any) {
348
- if (
349
- !osd?.IIIFTileSource?.prototype ||
350
- osd.__triiiceratopsIiifLevel0Patched
351
- )
352
- return;
353
-
354
- const proto = osd.IIIFTileSource.prototype;
355
- const originalConfigure = proto.configure;
356
- if (typeof originalConfigure !== 'function') return;
357
-
358
- proto.configure = function (data: any, url: string, postData: any) {
359
- const configured = originalConfigure.call(this, data, url, postData);
360
- return normalizeIiifLevel0Profile(configured);
361
- };
362
-
363
- osd.__triiiceratopsIiifLevel0Patched = true;
364
- }
365
-
366
- // Pre-fetch info.json URLs to detect 401 auth errors before passing to OSD
367
- async function resolveTileSources(
368
- sources: any[],
369
- ): Promise<
370
- | { ok: true; resolved: any[] }
371
- | { ok: false; error: { type: 'auth' } }
372
- > {
373
- const resolved = await Promise.all(
374
- sources.map(async (source) => {
375
- // Only probe string sources (info.json URLs); preserve string tile
376
- // sources so OSD follows its normal source-loading path.
377
- if (typeof source !== 'string')
378
- return normalizeIiifLevel0Profile(source);
379
- try {
380
- const response = await fetch(source);
381
- if (response.status === 401) {
382
- return { __authError: true };
383
- }
384
- if (response.ok) {
385
- try {
386
- normalizeIiifLevel0Profile(await response.json());
387
- } catch {
388
- // Not JSON or malformed response; let OSD handle source URL.
389
- }
390
- }
391
- return source;
392
- } catch {
393
- // Network errors: pass through and let OSD handle it
394
- return source;
395
- }
396
- }),
397
- );
398
-
399
- // Check if any source had an auth error
400
- if (resolved.some((r) => r && r.__authError)) {
401
- return { ok: false, error: { type: 'auth' } };
402
- }
403
-
404
- return { ok: true, resolved };
405
- }
406
-
407
278
  // Load tile source when it changes
408
279
  $effect(() => {
409
280
  if (!viewer) return;
@@ -467,7 +338,14 @@
467
338
  viewer.minZoomImageRatio = DEFAULT_MIN_ZOOM_IMAGE_RATIO;
468
339
  }
469
340
 
470
- resolveTileSources(sources).then((result) => {
341
+ resolveTileSources({
342
+ sources,
343
+ osd: OSD,
344
+ viewport: {
345
+ width: container?.clientWidth ?? 0,
346
+ height: container?.clientHeight ?? 0,
347
+ },
348
+ }).then((result) => {
471
349
  // Staleness guard: if tile sources changed while we were fetching, discard
472
350
  if (capturedKey !== lastTileSourceStr) return;
473
351
 
@@ -542,9 +420,8 @@
542
420
  (batchIdx + 1) * BATCH_SIZE,
543
421
  );
544
422
  if (batch.length === 0) {
545
- // Keep a modest margin below home zoom, but avoid the
546
- // extreme zoom-out range where some level-0 services
547
- // trigger invalid tile URLs.
423
+ // Keep a modest margin below home zoom in continuous
424
+ // mode to reduce empty over-zoom edge cases.
548
425
  if (overrides.minZoomLevel === undefined) {
549
426
  viewer.viewport.minZoomLevel =
550
427
  viewer.viewport.getHomeZoom() * 0.8;
@@ -585,62 +462,59 @@
585
462
  viewer.minZoomImageRatio = DEFAULT_MIN_ZOOM_IMAGE_RATIO;
586
463
  }
587
464
 
588
- const immediateSources = sources.map((source) =>
589
- typeof source === 'string'
590
- ? source
591
- : normalizeIiifLevel0Profile(source),
592
- );
593
-
594
- // Open immediately for perceived responsiveness.
595
- if (mode === 'paged' && immediateSources.length === 2) {
596
- const gap = 0.025;
597
- const offset = 1 + gap;
598
-
599
- // Two pages.
600
- // If LTR: [0] at 0, [1] at 1.025
601
- // If RTL: [0] at 1.025, [1] at 0
602
- const firstX = isPagedRTL ? offset : 0;
603
- const secondX = isPagedRTL ? 0 : offset;
604
-
605
- const spread = [
606
- {
607
- tileSource: immediateSources[0],
608
- x: firstX,
609
- y: 0,
610
- width: 1.0,
611
- },
612
- {
613
- tileSource: immediateSources[1],
614
- x: secondX,
615
- y: 0,
616
- width: 1.0,
617
- },
618
- ];
619
- viewer.open(spread);
620
- } else {
621
- // Individuals or single paged or fallback
622
- viewer.open(
623
- immediateSources.length === 1
624
- ? immediateSources[0]
625
- : immediateSources,
626
- );
627
- }
628
-
629
- // Pre-fetch info.json URLs in the background to detect auth errors
630
- // without delaying image display.
631
- resolveTileSources(sources).then((result) => {
465
+ resolveTileSources({
466
+ sources,
467
+ osd: OSD,
468
+ viewport: {
469
+ width: container?.clientWidth ?? 0,
470
+ height: container?.clientHeight ?? 0,
471
+ },
472
+ }).then((result) => {
632
473
  // Staleness guard: if tile sources changed while we were fetching, discard
633
474
  if (capturedKey !== lastTileSourceStr) return;
634
475
 
635
476
  if (!result.ok) {
636
477
  viewerState.tileSourceError = result.error;
637
- // Clear stale tiles from the previous canvas
638
478
  viewer.close();
639
479
  return;
640
480
  }
641
481
 
642
- // Clear any previous error
643
482
  viewerState.tileSourceError = null;
483
+ const resolvedSources = result.resolved;
484
+
485
+ if (mode === 'paged' && resolvedSources.length === 2) {
486
+ const gap = 0.025;
487
+ const offset = 1 + gap;
488
+
489
+ // Two pages.
490
+ // If LTR: [0] at 0, [1] at 1.025
491
+ // If RTL: [0] at 1.025, [1] at 0
492
+ const firstX = isPagedRTL ? offset : 0;
493
+ const secondX = isPagedRTL ? 0 : offset;
494
+
495
+ const spread = [
496
+ {
497
+ tileSource: resolvedSources[0],
498
+ x: firstX,
499
+ y: 0,
500
+ width: 1.0,
501
+ },
502
+ {
503
+ tileSource: resolvedSources[1],
504
+ x: secondX,
505
+ y: 0,
506
+ width: 1.0,
507
+ },
508
+ ];
509
+ viewer.open(spread);
510
+ } else {
511
+ // Individuals or single paged or fallback
512
+ viewer.open(
513
+ resolvedSources.length === 1
514
+ ? resolvedSources[0]
515
+ : resolvedSources,
516
+ );
517
+ }
644
518
  });
645
519
  });
646
520
 
@@ -0,0 +1,19 @@
1
+ export type TileSourceResolutionResult = {
2
+ ok: true;
3
+ resolved: any[];
4
+ } | {
5
+ ok: false;
6
+ error: {
7
+ type: 'auth';
8
+ };
9
+ };
10
+ type ResolveTileSourcesParams = {
11
+ sources: any[];
12
+ osd?: any;
13
+ viewport?: {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ };
18
+ export declare function resolveTileSources(params: ResolveTileSourcesParams): Promise<TileSourceResolutionResult>;
19
+ export {};
@@ -0,0 +1,206 @@
1
+ function isAuthErrorMarker(value) {
2
+ return (!!value &&
3
+ typeof value === 'object' &&
4
+ '__authError' in value &&
5
+ value.__authError === true);
6
+ }
7
+ function getProfileHead(profile) {
8
+ if (typeof profile === 'string')
9
+ return profile;
10
+ if (Array.isArray(profile) && typeof profile[0] === 'string') {
11
+ return profile[0];
12
+ }
13
+ return null;
14
+ }
15
+ function isIiifLevel0Profile(profile) {
16
+ const profileHead = getProfileHead(profile);
17
+ if (!profileHead)
18
+ return false;
19
+ return (profileHead === 'level0' ||
20
+ profileHead.endsWith('/level0.json') ||
21
+ profileHead.endsWith('#level0'));
22
+ }
23
+ function createIiifTileSource(osd, data, url, viewport) {
24
+ if (!osd?.IIIFTileSource?.prototype)
25
+ return data;
26
+ try {
27
+ const configured = osd.IIIFTileSource.prototype.configure.call({}, data, url, null);
28
+ const tileSource = new osd.IIIFTileSource(configured);
29
+ applyLevel0LowZoomFullImageStrategy(tileSource, viewport);
30
+ return tileSource;
31
+ }
32
+ catch {
33
+ return data;
34
+ }
35
+ }
36
+ function applyLevel0LowZoomFullImageStrategy(tileSource, viewport) {
37
+ if (!isIiifLevel0Profile(tileSource?.profile))
38
+ return;
39
+ if (typeof tileSource?.getTileUrl !== 'function')
40
+ return;
41
+ if (tileSource.__triiiceratopsLevel0LowZoomPrepared)
42
+ return;
43
+ const originalGetTileUrl = tileSource.getTileUrl.bind(tileSource);
44
+ const originalGetNumTiles = typeof tileSource.getNumTiles === 'function'
45
+ ? tileSource.getNumTiles.bind(tileSource)
46
+ : null;
47
+ const fitMaxLevel = getFitMaxLevel(tileSource, viewport);
48
+ const advertisedScaleFactors = getAdvertisedScaleFactors(tileSource);
49
+ const tiledOnlyMinLevel = getTiledOnlyMinLevel(tileSource, advertisedScaleFactors, originalGetNumTiles);
50
+ if (originalGetNumTiles) {
51
+ tileSource.getNumTiles = function (level) {
52
+ if (shouldHideLevel(level, tiledOnlyMinLevel, advertisedScaleFactors, this)) {
53
+ return { x: 0, y: 0 };
54
+ }
55
+ if (isFullImageLevel(this, level, fitMaxLevel, advertisedScaleFactors, originalGetNumTiles, tiledOnlyMinLevel)) {
56
+ return { x: 1, y: 1 };
57
+ }
58
+ return originalGetNumTiles(level);
59
+ };
60
+ }
61
+ if (typeof tileSource?.minLevel === 'number' && tiledOnlyMinLevel >= 0) {
62
+ tileSource.minLevel = Math.max(tileSource.minLevel, tiledOnlyMinLevel);
63
+ }
64
+ tileSource.getTileUrl = function (level, x, y) {
65
+ if (shouldHideLevel(level, tiledOnlyMinLevel, advertisedScaleFactors, this)) {
66
+ const safeLevel = tiledOnlyMinLevel >= 0 ? tiledOnlyMinLevel : level;
67
+ return originalGetTileUrl(safeLevel, x, y);
68
+ }
69
+ if (isFullImageLevel(this, level, fitMaxLevel, advertisedScaleFactors, originalGetNumTiles, tiledOnlyMinLevel)) {
70
+ return getFullImageUrlForLevel(this, level);
71
+ }
72
+ return originalGetTileUrl(level, x, y);
73
+ };
74
+ tileSource.__triiiceratopsLevel0LowZoomPrepared = true;
75
+ }
76
+ function isFullImageLevel(tileSource, level, fitMaxLevel, advertisedScaleFactors, getNumTilesBase, tiledOnlyMinLevel) {
77
+ if (tiledOnlyMinLevel >= 0)
78
+ return false;
79
+ if (fitMaxLevel >= 0 && level <= fitMaxLevel)
80
+ return true;
81
+ if (!isAdvertisedTileLevel(tileSource, level, advertisedScaleFactors)) {
82
+ return true;
83
+ }
84
+ if (!getNumTilesBase)
85
+ return false;
86
+ const numTiles = getNumTilesBase(level);
87
+ return numTiles?.x === 1 && numTiles?.y === 1;
88
+ }
89
+ function getTiledOnlyMinLevel(tileSource, advertisedScaleFactors, getNumTilesBase) {
90
+ if (!getNumTilesBase)
91
+ return -1;
92
+ if (typeof tileSource?.minLevel !== 'number' ||
93
+ typeof tileSource?.maxLevel !== 'number')
94
+ return -1;
95
+ if (advertisedScaleFactors.size === 0)
96
+ return -1;
97
+ for (let level = tileSource.minLevel; level <= tileSource.maxLevel; level++) {
98
+ if (!isAdvertisedTileLevel(tileSource, level, advertisedScaleFactors)) {
99
+ continue;
100
+ }
101
+ const tiles = getNumTilesBase(level);
102
+ if (tiles?.x > 1 || tiles?.y > 1) {
103
+ return level;
104
+ }
105
+ }
106
+ return -1;
107
+ }
108
+ function shouldHideLevel(level, tiledOnlyMinLevel, advertisedScaleFactors, tileSource) {
109
+ if (tiledOnlyMinLevel < 0)
110
+ return false;
111
+ if (level < tiledOnlyMinLevel)
112
+ return true;
113
+ return !isAdvertisedTileLevel(tileSource, level, advertisedScaleFactors);
114
+ }
115
+ function getAdvertisedScaleFactors(tileSource) {
116
+ const result = new Set();
117
+ const factors = tileSource?.scale_factors;
118
+ if (Array.isArray(factors)) {
119
+ for (const value of factors) {
120
+ if (typeof value === 'number' && value > 0)
121
+ result.add(value);
122
+ }
123
+ }
124
+ return result;
125
+ }
126
+ function isAdvertisedTileLevel(tileSource, level, advertisedScaleFactors) {
127
+ if (advertisedScaleFactors.size === 0)
128
+ return true;
129
+ if (typeof tileSource?.maxLevel !== 'number' ||
130
+ typeof level !== 'number') {
131
+ return true;
132
+ }
133
+ const scaleFactor = Math.pow(2, tileSource.maxLevel - level);
134
+ return advertisedScaleFactors.has(scaleFactor);
135
+ }
136
+ function getFitMaxLevel(tileSource, viewport) {
137
+ if (!viewport || viewport.width <= 0 || viewport.height <= 0)
138
+ return -1;
139
+ if (typeof tileSource?.minLevel !== 'number' ||
140
+ typeof tileSource?.maxLevel !== 'number' ||
141
+ typeof tileSource?.getLevelScale !== 'function' ||
142
+ typeof tileSource?.width !== 'number' ||
143
+ typeof tileSource?.height !== 'number')
144
+ return -1;
145
+ let fitMaxLevel = -1;
146
+ for (let level = tileSource.minLevel; level <= tileSource.maxLevel; level++) {
147
+ const scale = tileSource.getLevelScale(level);
148
+ const levelWidth = Math.ceil(tileSource.width * scale);
149
+ const levelHeight = Math.ceil(tileSource.height * scale);
150
+ if (levelWidth <= viewport.width && levelHeight <= viewport.height) {
151
+ fitMaxLevel = level;
152
+ }
153
+ }
154
+ return fitMaxLevel;
155
+ }
156
+ function getFullImageUrlForLevel(tileSource, level) {
157
+ const scale = tileSource.getLevelScale(level);
158
+ const levelWidth = Math.ceil(tileSource.width * scale);
159
+ const levelHeight = Math.ceil(tileSource.height * scale);
160
+ const isVersion3 = tileSource.version === 3;
161
+ const isLevel0 = isIiifLevel0Profile(tileSource?.profile);
162
+ const size = isVersion3
163
+ ? levelWidth === tileSource.width && levelHeight === tileSource.height
164
+ ? 'max'
165
+ : isLevel0
166
+ ? `${levelWidth},`
167
+ : `${levelWidth},${levelHeight}`
168
+ : levelWidth === tileSource.width
169
+ ? 'full'
170
+ : `${levelWidth},`;
171
+ const quality = isVersion3 ? 'default' : 'native';
172
+ return `${tileSource._id}/full/${size}/0/${quality}.${tileSource.tileFormat}`;
173
+ }
174
+ // Fetch string info.json URLs once to detect auth errors and build prepared
175
+ // sources for OSD. Non-string tile sources are passed through unchanged.
176
+ export async function resolveTileSources(params) {
177
+ const { sources, osd, viewport } = params;
178
+ const resolved = await Promise.all(sources.map(async (source) => {
179
+ if (typeof source !== 'string')
180
+ return source;
181
+ try {
182
+ const response = await fetch(source);
183
+ if (response.status === 401) {
184
+ return { __authError: true };
185
+ }
186
+ if (!response.ok)
187
+ return source;
188
+ let data = null;
189
+ try {
190
+ data = await response.json();
191
+ }
192
+ catch {
193
+ return source;
194
+ }
195
+ return createIiifTileSource(osd, data, source, viewport);
196
+ }
197
+ catch {
198
+ // Network errors: pass through and let OSD handle it.
199
+ return source;
200
+ }
201
+ }));
202
+ if (resolved.some(isAuthErrorMarker)) {
203
+ return { ok: false, error: { type: 'auth' } };
204
+ }
205
+ return { ok: true, resolved };
206
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,161 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { resolveTileSources } from './osdTileSources';
3
+ class FakeIIIFTileSource {
4
+ width;
5
+ height;
6
+ _id;
7
+ version;
8
+ tileFormat;
9
+ profile;
10
+ scale_factors;
11
+ minLevel = 0;
12
+ maxLevel = 2;
13
+ __triiiceratopsLevel0LowZoomPrepared;
14
+ constructor(options) {
15
+ this.width = options.width;
16
+ this.height = options.height;
17
+ this._id = options._id || options.id || options['@id'];
18
+ this.version = options.version ?? 3;
19
+ this.tileFormat = options.tileFormat ?? 'jpg';
20
+ this.profile = options.profile;
21
+ this.scale_factors = options.scale_factors ?? [1, 2, 4];
22
+ }
23
+ configure(data, url) {
24
+ return {
25
+ ...data,
26
+ _id: data._id || data.id || data['@id'] || url.replace('/info.json', ''),
27
+ width: data.width ?? 4000,
28
+ height: data.height ?? 3000,
29
+ version: data.version ?? 3,
30
+ tileFormat: data.tileFormat ?? 'jpg',
31
+ };
32
+ }
33
+ getTileUrl(level, x, y) {
34
+ return `tile/${level}/${x}/${y}`;
35
+ }
36
+ getLevelScale(level) {
37
+ return [0.25, 0.5, 1][level] ?? 1;
38
+ }
39
+ getNumTiles(level) {
40
+ if (level <= 1)
41
+ return { x: 1, y: 1 };
42
+ return { x: 2, y: 2 };
43
+ }
44
+ }
45
+ describe('resolveTileSources', () => {
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+ it('passes through non-string sources unchanged', async () => {
50
+ const objectSource = {
51
+ '@context': 'http://iiif.io/api/image/2/context.json',
52
+ profile: 'https://iiif.io/api/image/2/level0.json',
53
+ };
54
+ const result = await resolveTileSources({ sources: [objectSource] });
55
+ expect(result.ok).toBe(true);
56
+ if (result.ok) {
57
+ expect(result.resolved[0]).toBe(objectSource);
58
+ expect(result.resolved[0].profile).toBe('https://iiif.io/api/image/2/level0.json');
59
+ }
60
+ });
61
+ it('fetches info.json once and returns parsed source for non-401 responses', async () => {
62
+ const fetchSpy = vi
63
+ .spyOn(globalThis, 'fetch')
64
+ .mockResolvedValue({
65
+ status: 200,
66
+ ok: true,
67
+ json: async () => ({ width: 1000, height: 800 }),
68
+ });
69
+ const source = 'https://example.org/iiif/image/info.json';
70
+ const result = await resolveTileSources({ sources: [source] });
71
+ expect(fetchSpy).toHaveBeenCalledWith(source);
72
+ expect(result.ok).toBe(true);
73
+ if (result.ok) {
74
+ expect(result.resolved[0]).toEqual({ width: 1000, height: 800 });
75
+ }
76
+ });
77
+ it('returns auth error when any string source responds with 401', async () => {
78
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
79
+ const url = String(input);
80
+ const isProtected = url.includes('protected');
81
+ return {
82
+ status: isProtected ? 401 : 200,
83
+ ok: !isProtected,
84
+ json: async () => ({ width: 1000, height: 800 }),
85
+ };
86
+ });
87
+ const result = await resolveTileSources({
88
+ sources: [
89
+ 'https://example.org/public/info.json',
90
+ 'https://example.org/protected/info.json',
91
+ ],
92
+ });
93
+ expect(result).toEqual({ ok: false, error: { type: 'auth' } });
94
+ });
95
+ it('hides low single-tile levels and preserves tiled zoom-in for level-0', async () => {
96
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
97
+ status: 200,
98
+ ok: true,
99
+ json: async () => ({
100
+ '@context': 'http://iiif.io/api/image/3/context.json',
101
+ id: 'https://example.org/iiif/image',
102
+ width: 4000,
103
+ height: 3000,
104
+ version: 3,
105
+ profile: 'https://iiif.io/api/image/3/level0.json',
106
+ scale_factors: [1, 2, 4],
107
+ }),
108
+ });
109
+ const osd = { IIIFTileSource: FakeIIIFTileSource };
110
+ const result = await resolveTileSources({
111
+ sources: ['https://example.org/iiif/image/info.json'],
112
+ osd,
113
+ viewport: { width: 1200, height: 900 },
114
+ });
115
+ expect(result.ok).toBe(true);
116
+ if (result.ok) {
117
+ const source = result.resolved[0];
118
+ expect(source.minLevel).toBe(2);
119
+ expect(source.getNumTiles(0)).toEqual({ x: 0, y: 0 });
120
+ expect(source.getNumTiles(1)).toEqual({ x: 0, y: 0 });
121
+ expect(source.getNumTiles(2)).toEqual({ x: 2, y: 2 });
122
+ expect(source.getTileUrl(2, 1, 0)).toBe('tile/2/1/0');
123
+ }
124
+ });
125
+ it('forces full-image requests for unadvertised scale-factor levels', async () => {
126
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
127
+ status: 200,
128
+ ok: true,
129
+ json: async () => ({
130
+ '@context': 'http://iiif.io/api/image/3/context.json',
131
+ id: 'https://example.org/iiif/image',
132
+ width: 4000,
133
+ height: 3000,
134
+ version: 3,
135
+ profile: 'https://iiif.io/api/image/3/level0.json',
136
+ // level scale factors for maxLevel=2 are [4,2,1]; omit 2.
137
+ scale_factors: [1, 4],
138
+ }),
139
+ });
140
+ const osd = { IIIFTileSource: FakeIIIFTileSource };
141
+ const result = await resolveTileSources({
142
+ sources: ['https://example.org/iiif/image/info.json'],
143
+ osd,
144
+ viewport: { width: 500, height: 500 },
145
+ });
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ const source = result.resolved[0];
149
+ expect(source.getNumTiles(1)).toEqual({ x: 0, y: 0 });
150
+ expect(source.getTileUrl(1, 0, 0)).toBe('tile/2/0/0');
151
+ // Highest level is still advertised and remains tiled.
152
+ expect(source.getTileUrl(2, 1, 0)).toBe('tile/2/1/0');
153
+ }
154
+ });
155
+ it('passes through string sources on fetch/network failure', async () => {
156
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'));
157
+ const source = 'https://example.org/iiif/image/info.json';
158
+ const result = await resolveTileSources({ sources: [source] });
159
+ expect(result).toEqual({ ok: true, resolved: [source] });
160
+ });
161
+ });