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.
- package/dist/components/OSDViewer.svelte +56 -182
- package/dist/components/osdTileSources.d.ts +19 -0
- package/dist/components/osdTileSources.js +206 -0
- package/dist/components/osdTileSources.test.d.ts +1 -0
- package/dist/components/osdTileSources.test.js +161 -0
- package/dist/triiiceratops-bundle.js +3492 -3409
- package/dist/triiiceratops-element.iife.js +27 -27
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
224
|
-
//
|
|
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(
|
|
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
|
|
546
|
-
//
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
+
});
|