cozy-iiif 0.1.0

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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "cozy-iiif",
3
+ "version": "0.1.0",
4
+ "description": "A developer-friendly collection of abstractions and utilities built on top of @iiif/presentation-3 and @iiif/parser",
5
+ "license": "MIT",
6
+ "author": "Rainer Simon",
7
+ "keywords": [
8
+ "IIIF"
9
+ ],
10
+ "homepage": "https://github.com/rsimon/cozy-iiif#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/rsimon/cozy-iiif/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/rsimon/cozy-iiif.git"
17
+ },
18
+ "type": "module",
19
+ "main": "dist/index.js",
20
+ "types": "dist/index.d.ts",
21
+ "scripts": {
22
+ "build": "vite build"
23
+ },
24
+ "exports": {
25
+ ".": "./dist/index.js",
26
+ "./level-0": "./dist/level-0/index.js"
27
+ },
28
+ "devDependencies": {
29
+ "vite": "^6.1.0",
30
+ "vite-plugin-dts": "^4.5.0"
31
+ },
32
+ "dependencies": {
33
+ "@iiif/parser": "^2.1.7",
34
+ "@iiif/presentation-3": "^2.2.3",
35
+ "p-throttle": "^7.0.0"
36
+ }
37
+ }
package/src/Cozy.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { Canvas, Manifest } from '@iiif/presentation-3';
2
+ import { convertPresentation2 } from '@iiif/parser/presentation-2';
3
+ import { Traverse } from '@iiif/parser';
4
+ import { CozyCanvas, CozyManifest, CozyParseResult, ImageServiceResource } from './types';
5
+ import {
6
+ getImages,
7
+ getLabel,
8
+ getMetadata,
9
+ getPropertyValue,
10
+ getThumbnailURL,
11
+ normalizeServiceUrl,
12
+ parseImageService
13
+ } from './core';
14
+
15
+ export const Cozy = {
16
+
17
+ parseURL: async (input: string): Promise<CozyParseResult> => {
18
+ try {
19
+ new URL(input);
20
+ } catch {
21
+ return {
22
+ type: 'error',
23
+ code: 'INVALID_URL',
24
+ message: 'The provided input is not a valid URL'
25
+ };
26
+ }
27
+
28
+ let response: Response;
29
+
30
+ try {
31
+ response = await fetch(input);
32
+ if (!response.ok) {
33
+ return {
34
+ type: 'error',
35
+ code: 'INVALID_HTTP_RESPONSE',
36
+ message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
37
+ }
38
+ }
39
+ } catch (error) {
40
+ return {
41
+ type: 'error',
42
+ code: 'FETCH_ERROR',
43
+ message: error instanceof Error ? error.message : 'Failed to fetch resource'
44
+ };
45
+ }
46
+
47
+ const contentType = response.headers.get('content-type');
48
+
49
+ if (contentType?.startsWith('image/')) {
50
+ return {
51
+ type: 'plain-image',
52
+ url: input
53
+ };
54
+ }
55
+
56
+ if (contentType?.includes('text/html')) {
57
+ return {
58
+ type: 'webpage',
59
+ url: input
60
+ };
61
+ }
62
+
63
+ try {
64
+ const json = await response.json();
65
+
66
+ const context = Array.isArray(json['@context'])
67
+ ? json['@context'].find(str => str.includes('iiif.io/api/'))
68
+ : json['@context'];
69
+
70
+ if (!context) {
71
+ return {
72
+ type: 'error',
73
+ code: 'INVALID_MANIFEST',
74
+ message: 'Missing @context'
75
+ }
76
+ };
77
+
78
+ const id = getPropertyValue<string>(json, 'id');
79
+
80
+ if (!id) {
81
+ return {
82
+ type: 'error',
83
+ code: 'INVALID_MANIFEST',
84
+ message: 'Missing id property'
85
+ }
86
+ }
87
+
88
+ if (context.includes('presentation/2') || context.includes('presentation/3')) {
89
+ const majorVersion = context.includes('presentation/2') ? 2 : 3;
90
+
91
+ return {
92
+ type: 'manifest',
93
+ url: input,
94
+ resource: parseManifestResource(json, majorVersion)
95
+ };
96
+ }
97
+
98
+ if (context.includes('image/2') || context.includes('image/3')) {
99
+ const resource = parseImageResource(json);
100
+ return resource ? {
101
+ type: 'iiif-image',
102
+ url: input,
103
+ resource
104
+ } : {
105
+ type: 'error',
106
+ code: 'INVALID_MANIFEST',
107
+ message: 'Invalid image service definition'
108
+ }
109
+ }
110
+
111
+ return {
112
+ type: 'error',
113
+ code: 'INVALID_MANIFEST',
114
+ message: 'JSON resource is not a recognized IIIF format'
115
+ };
116
+ } catch {
117
+ return {
118
+ type: 'error',
119
+ code: 'UNSUPPORTED_FORMAT',
120
+ message: 'Could not parse resource'
121
+ };
122
+ }
123
+
124
+ }
125
+
126
+ }
127
+
128
+ const parseManifestResource = (resource: any, majorVersion: number): CozyManifest => {
129
+
130
+ const parseV3 = (manifest: Manifest) => {
131
+ const canvases: Canvas[] = [];
132
+
133
+ const modelBuilder = new Traverse({
134
+ canvas: [canvas => { if (canvas.items) canvases.push(canvas) }]
135
+ });
136
+
137
+ modelBuilder.traverseManifest(manifest);
138
+
139
+ return canvases.map((c: Canvas) => {
140
+ const images = getImages(c);
141
+ return {
142
+ source: c,
143
+ id: c.id,
144
+ width: c.width,
145
+ height: c.height,
146
+ images,
147
+ getLabel: getLabel(c),
148
+ getMetadata: getMetadata(c),
149
+ getThumbnailURL: getThumbnailURL(c, images)
150
+ } as CozyCanvas;
151
+ });
152
+ }
153
+
154
+ const v3: Manifest = majorVersion === 2 ? convertPresentation2(resource) : resource;
155
+
156
+ const canvases = parseV3(v3);
157
+
158
+ return {
159
+ source: v3,
160
+ id: v3.id,
161
+ majorVersion,
162
+ canvases,
163
+ getLabel: getLabel(v3),
164
+ getMetadata: getMetadata(v3)
165
+ }
166
+ }
167
+
168
+ const parseImageResource = (resource: any) => {
169
+ const { width, height } = resource;
170
+
171
+ const service = parseImageService(resource);
172
+ if (service) {
173
+ return {
174
+ type: service.profileLevel === 0 ? 'level0' : 'dynamic',
175
+ service: resource,
176
+ width,
177
+ height,
178
+ majorVersion: service.majorVersion,
179
+ serviceUrl: normalizeServiceUrl(getPropertyValue<string>(resource, 'id'))
180
+ } as ImageServiceResource;
181
+ }
182
+ }
@@ -0,0 +1,107 @@
1
+ import { Canvas, IIIFExternalWebResource } from '@iiif/presentation-3';
2
+ import { Traverse } from '@iiif/parser';
3
+ import { getPropertyValue } from './resource';
4
+ import { getImageURLFromService, getRegionURL, isImageService, parseImageService } from './image-service';
5
+ import {
6
+ CozyImageResource,
7
+ DynamicImageServiceResource,
8
+ ImageServiceResource,
9
+ Level0ImageServiceResource,
10
+ StaticImageResource
11
+ } from '../types';
12
+
13
+ export const getThumbnailURL = (canvas: Canvas, images: CozyImageResource[] = []) => (minSize = 400) => {
14
+ const { width, height } = canvas;
15
+
16
+ if (!width || !height) return;
17
+
18
+ const aspect = width / height;
19
+ const isPortrait = aspect < 1;
20
+
21
+ const h = Math.ceil(isPortrait ? minSize / aspect : minSize);
22
+ const w = Math.ceil(isPortrait ? minSize : minSize / aspect);
23
+
24
+ if (canvas.thumbnail && canvas.thumbnail.length > 0) {
25
+ const thumbnail = canvas.thumbnail[0];
26
+
27
+ if ('service' in thumbnail && Array.isArray(thumbnail.service)) {
28
+ const service = thumbnail.service.find(s => isImageService(s));
29
+ if (service)
30
+ return getImageURLFromService(service, w, h);
31
+ }
32
+
33
+ if ('id' in thumbnail) return thumbnail.id;
34
+ }
35
+
36
+ for (const image of images) {
37
+ if (image.type === 'dynamic' || image.type === 'level0') {
38
+ return getImageURLFromService(image.service, w, h);
39
+ } else if (image.type === 'static') {
40
+ // console.warn('Static image canvas');
41
+ return image.url;
42
+ }
43
+ }
44
+ }
45
+
46
+ export const normalizeServiceUrl = (url: string) =>
47
+ url.endsWith('/info.json') ? url : `${url.endsWith('/') ? url : `${url}/`}info.json`;
48
+
49
+ const toCozyImageResource = (resource: IIIFExternalWebResource) => {
50
+ const { format, height, width } = resource;
51
+
52
+ const id = getPropertyValue(resource, 'id');
53
+
54
+ const imageService = (resource.service || []).find(isImageService);
55
+
56
+ const service = imageService ? parseImageService(imageService) : undefined;
57
+ if (service) {
58
+ const image = {
59
+ source: resource,
60
+ type: service.profileLevel === 0 ? 'level0' : 'dynamic',
61
+ service: imageService,
62
+ width,
63
+ height,
64
+ majorVersion: service.majorVersion,
65
+ serviceUrl: normalizeServiceUrl(getPropertyValue<string>(imageService, 'id'))
66
+ } as ImageServiceResource;
67
+
68
+ if (service.profileLevel === 0) {
69
+ return image as Level0ImageServiceResource;
70
+ } else {
71
+ return {
72
+ ...image,
73
+ getRegionURL: getRegionURL(image)
74
+ } as DynamicImageServiceResource;
75
+ }
76
+ } else {
77
+ return {
78
+ source: resource,
79
+ type: 'static',
80
+ width,
81
+ height,
82
+ url: id,
83
+ format
84
+ } as StaticImageResource;
85
+ }
86
+ }
87
+
88
+ export const getImages = (canvas: Canvas): CozyImageResource[] => {
89
+ const images: CozyImageResource[] = [];
90
+
91
+ const builder = new Traverse({
92
+ annotation: [anno => {
93
+ if (anno.motivation === 'painting') {
94
+ const bodies = anno.body ?
95
+ Array.isArray(anno.body) ? anno.body : [anno.body]
96
+ : [];
97
+
98
+ const imageResources = bodies.filter(b => (b as IIIFExternalWebResource).type === 'Image');
99
+ images.push(...imageResources.map(toCozyImageResource));
100
+ }
101
+ }]
102
+ });
103
+
104
+ builder.traverseCanvas(canvas);
105
+
106
+ return images;
107
+ }
@@ -0,0 +1,103 @@
1
+ import { ImageService2, ImageService3, Service } from '@iiif/presentation-3';
2
+ import { getPropertyValue } from './resource';
3
+ import { Bounds, CozyImageResource } from '../types';
4
+
5
+ type ImageService = ImageService2 | ImageService3;
6
+
7
+ export const isImageService = (data: any): data is ImageService => {
8
+ const t = getPropertyValue<string>(data, 'type');
9
+ return t.startsWith('ImageService');
10
+ }
11
+
12
+ export const parseImageService = (service: Service) => {
13
+ const t = getPropertyValue<string>(service, 'type');
14
+ const context = getPropertyValue<string>(service, 'context');
15
+
16
+ if (t === 'ImageService2' || context?.includes('image/2')) {
17
+ const service2 = service as ImageService2;
18
+
19
+ const labels = ['level0', 'level1', 'level2'];
20
+ const profiles = Array.isArray(service2.profile) ? service2.profile : [service2.profile];
21
+
22
+ const levels = profiles
23
+ .map(profile => labels.findIndex(level => profile.toString().includes(level)))
24
+ .filter(l => l > -1)
25
+ .sort((a, b) => b - a); // Sort descending
26
+
27
+ return { majorVersion: 2, profileLevel: levels[0] };
28
+ } else if (t || context) {
29
+ // Image API 3
30
+ const service3 = service as ImageService3;
31
+ return { majorVersion: 3, profileLevel: parseInt(service3.profile)}
32
+ }
33
+ }
34
+
35
+ export const getImageURLFromService = (
36
+ service: ImageService2 | ImageService3,
37
+ width: number,
38
+ height: number
39
+ ): string => {
40
+ const id = getPropertyValue(service, 'id');
41
+
42
+ const compliance = service.profile || '';
43
+
44
+ const isLevel0 = typeof compliance === 'string' &&
45
+ (compliance.includes('level0') || compliance.includes('level:0'));
46
+
47
+ if (isLevel0) {
48
+ // For level 0, find the closest pre-defined size
49
+ if ('sizes' in service && Array.isArray(service.sizes)) {
50
+ const suitableSize = service.sizes
51
+ .sort((a, b) => (b.width * b.height) - (a.width * a.height))
52
+ .filter(s => (s.width * s.height) >= width * height)[0];
53
+
54
+ if (suitableSize)
55
+ return `${id}/full/${suitableSize.width},${suitableSize.height}/0/default.jpg`;
56
+ }
57
+
58
+ // Fallback: full image
59
+ return `${id}/full/full/0/default.jpg`;
60
+ }
61
+
62
+ return `${id}/full/!${width},${height}/0/default.jpg`;
63
+ }
64
+
65
+ export const getRegionURLFromService = (
66
+ service: ImageService2 | ImageService3,
67
+ bounds: Bounds,
68
+ minSize: number
69
+ ): string => {
70
+ const id = getPropertyValue(service, 'id');
71
+ const compliance = service.profile || '';
72
+
73
+ const isLevel0 = typeof compliance === 'string' &&
74
+ (compliance.includes('level0') || compliance.includes('level:0'));
75
+
76
+ // TODO
77
+ if (isLevel0) return;
78
+
79
+ const { x, y, w , h } = bounds;
80
+
81
+ const aspect = w / h;
82
+ const isPortrait = aspect < 1;
83
+
84
+ const height = Math.ceil(isPortrait ? minSize / aspect : minSize);
85
+ const width = Math.ceil(isPortrait ? minSize : minSize / aspect);
86
+
87
+ const regionParam = `${Math.round(x)},${Math.round(y)},${Math.round(w)},${Math.round(h)}`;
88
+ return `${id}/${regionParam}/!${width},${height}/0/default.jpg`;
89
+ }
90
+
91
+ export const getRegionURL = (
92
+ image: CozyImageResource
93
+ ) => (
94
+ bounds: Bounds,
95
+ minSize = 400
96
+ ): string | undefined => {
97
+ if (image.type === 'dynamic') {
98
+ return getRegionURLFromService(image.service, bounds, minSize);
99
+ } else {
100
+ // TODO
101
+ console.error('Level 0 or static image canvas: unspported');
102
+ }
103
+ }
@@ -0,0 +1,3 @@
1
+ export * from './canvas';
2
+ export * from './image-service';
3
+ export * from './resource';
@@ -0,0 +1,41 @@
1
+ import { InternationalString, MetadataItem } from '@iiif/presentation-3';
2
+ import { CozyMetadata } from '../types';
3
+
4
+ export const getPropertyValue = <T extends unknown = any>(data: any, name: string) => {
5
+ let prop: any = data[name];
6
+
7
+ if (!prop)
8
+ prop = data[`@${name}`];
9
+
10
+ return prop as T;
11
+ }
12
+
13
+ export const getStringValue = (propertyValue: string | InternationalString, locale = 'en') => {
14
+ if (typeof propertyValue === 'string') return propertyValue;
15
+
16
+ const localized = propertyValue[locale];
17
+ if (localized) {
18
+ return localized[0];
19
+ } else {
20
+ const values = Object.values(propertyValue).reduce<string[]>((flattened, value) => {
21
+ return Array.isArray(value) ? [...flattened, ...value] : [...flattened, value]
22
+ }, []);
23
+
24
+ return values.length > 0 ? values[0] : undefined;
25
+ }
26
+ }
27
+
28
+ export const getLabel = (data: any) => (locale = 'en') => {
29
+ const propertyValue = getPropertyValue<string | InternationalString>(data, 'label');
30
+ return propertyValue ? getStringValue(propertyValue, locale) : undefined;
31
+ }
32
+
33
+ export const getMetadata = (data: any) => (locale?: string): CozyMetadata[] => {
34
+ const metadata = getPropertyValue(data, 'metadata') as MetadataItem[];
35
+ if (!metadata) return [];
36
+
37
+ return metadata.map(({ label, value }) => ({
38
+ label: getStringValue(label, locale),
39
+ value: getStringValue(value, locale)
40
+ }));
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core';
2
+ export * from './types';
3
+ export * from './Cozy';
@@ -0,0 +1,109 @@
1
+ import { Bounds, Level0ImageServiceResource } from '../types';
2
+ import { getThrottledLoader } from './throttled-loader';
3
+ import { ImageInfo, Tile } from './types';
4
+
5
+ const getTileUrl = (info: ImageInfo, bounds: Bounds): string => {
6
+ const { x, y, w, h } = bounds;
7
+ const tileWidth = info.tiles[0].width;
8
+ const tileHeight = info.tiles[0].height|| info.tiles[0].width;
9
+ return `${info['@id']}/${x * tileWidth},${y * tileHeight},${w},${h}/${tileWidth},/0/default.jpg`;
10
+ }
11
+
12
+ const getTilesForRegion = (info: ImageInfo, bounds: Bounds): Tile[] => {
13
+ const tileWidth = info.tiles[0].width;
14
+ const tileHeight = info.tiles[0].height || info.tiles[0].width; // fallback for square tiles
15
+ const maxWidth = info.width;
16
+ const maxHeight = info.height;
17
+
18
+ const startTileX = Math.floor(bounds.x / tileWidth);
19
+ const startTileY = Math.floor(bounds.y / tileHeight);
20
+ const endTileX = Math.ceil((bounds.x + bounds.w) / tileWidth);
21
+ const endTileY = Math.ceil((bounds.y + bounds.h) / tileHeight);
22
+
23
+ const tiles: Tile[] = [];
24
+
25
+ for (let y = startTileY; y < endTileY; y++) {
26
+ for (let x = startTileX; x < endTileX; x++) {
27
+ // Skip tiles outside image bounds
28
+ if (x * tileWidth >= maxWidth || y * tileHeight >= maxHeight) {
29
+ continue;
30
+ }
31
+
32
+ // Calculate actual tile dimensions (might be smaller at edges)
33
+ const effectiveWidth = Math.min(tileWidth, maxWidth - (x * tileWidth));
34
+ const effectiveHeight = Math.min(tileHeight, maxHeight - (y * tileHeight));
35
+
36
+ tiles.push({
37
+ x,
38
+ y,
39
+ width: effectiveWidth,
40
+ height: effectiveHeight,
41
+ url: getTileUrl(info, { x, y, w: effectiveWidth, h: effectiveHeight })
42
+ });
43
+ }
44
+ }
45
+
46
+ return tiles;
47
+ }
48
+
49
+ export const cropRegion = async (resource: Level0ImageServiceResource, bounds: Bounds): Promise<Blob> => {
50
+ const info = await fetch(resource.serviceUrl).then(res => res.json());
51
+
52
+ const tiles = getTilesForRegion(info, bounds);
53
+
54
+ const canvas = document.createElement('canvas');
55
+ const ctx = canvas.getContext('2d');
56
+
57
+ if (!ctx)
58
+ // Should never happen
59
+ throw new Error('Error initializing canvas context');
60
+
61
+ const tileWidth = info.tiles[0].width;
62
+ const tileHeight = info.tiles[0].height || info.tiles[0].width;
63
+
64
+ const tilesWidth = (Math.ceil(bounds.w / tileWidth) + 1) * tileWidth;
65
+ const tilesHeight = (Math.ceil(bounds.h / tileHeight) + 1) * tileHeight;
66
+
67
+ canvas.width = tilesWidth;
68
+ canvas.height = tilesHeight;
69
+
70
+ const loader = getThrottledLoader({ callsPerSecond: 20 });
71
+
72
+ // TODO implement polite harvesting!
73
+ await Promise.all(tiles.map(async (tile) => {
74
+ const img = await loader.loadImage(tile.url);
75
+ const x = (tile.x * tileWidth) - bounds.x;
76
+ const y = (tile.y * tileHeight) - bounds.y;
77
+ ctx.drawImage(img, x, y);
78
+ }));
79
+
80
+ const cropCanvas = document.createElement('canvas');
81
+ cropCanvas.width = bounds.w;
82
+ cropCanvas.height = bounds.h;
83
+
84
+ const cropCtx = cropCanvas.getContext('2d');
85
+
86
+ if (!cropCtx)
87
+ throw new Error('Error initializing canvas context');
88
+
89
+ // Copy cropped region
90
+ cropCtx.drawImage(canvas,
91
+ 0, 0, bounds.w, bounds.h,
92
+ 0, 0, bounds.w, bounds.h
93
+ );
94
+
95
+ return new Promise((resolve, reject) => {
96
+ cropCanvas.toBlob(
97
+ (blob) => {
98
+ if (blob) {
99
+ resolve(blob);
100
+ } else {
101
+ reject(new Error('Failed to create blob'));
102
+ }
103
+ },
104
+ 'image/jpeg',
105
+ 0.95
106
+ );
107
+ });
108
+
109
+ }
@@ -0,0 +1,113 @@
1
+ import { Level0ImageServiceResource } from '../types';
2
+ import { getThrottledLoader } from './throttled-loader';
3
+ import { ImageInfo, Size, Tile } from './types';
4
+
5
+ const getBestScaleFactor = (info: ImageInfo, minSize?: Partial<Size>): number => {
6
+ // Sort descending
7
+ const scaleFactors = info.tiles[0].scaleFactors.sort((a, b) => b - a);
8
+
9
+ if (!minSize)
10
+ // Just return highest scale factor
11
+ return scaleFactors[0];
12
+
13
+ const scaleX = minSize.width ? info.width / minSize.width : Infinity;
14
+ const scaleY = minSize.height ? info.height / minSize.height : Infinity;
15
+
16
+ const scale = Math.min(scaleX, scaleY);
17
+
18
+ // Find the smallest scale factor that still meets our minimum size requirements
19
+ for (const factor of scaleFactors) {
20
+ if (factor <= scale)
21
+ return factor;
22
+ }
23
+
24
+ // If no scale factor is small enough, return the smallest available
25
+ return scaleFactors[scaleFactors.length - 1];
26
+ };
27
+
28
+ const getThumbnailDimensions = (info: ImageInfo, minSize?: Partial<Size>): Size => {
29
+ const scaleFactor = getBestScaleFactor(info, minSize);
30
+
31
+ let width = Math.ceil(info.width / scaleFactor);
32
+ let height = Math.ceil(info.height / scaleFactor);
33
+
34
+ if (minSize) {
35
+ const aspectRatio = info.width / info.height;
36
+
37
+ if (minSize.width && width < minSize.width) {
38
+ width = minSize.width;
39
+ height = Math.ceil(width / aspectRatio);
40
+ }
41
+
42
+ if (minSize.height && height < minSize.height) {
43
+ height = minSize.height;
44
+ width = Math.ceil(height * aspectRatio);
45
+ }
46
+ }
47
+
48
+ return { width, height };
49
+ }
50
+
51
+ const getThumbnailTiles = (info: ImageInfo, minSize?: Partial<Size>): Tile[] => {
52
+ const scaleFactor = getBestScaleFactor(info, minSize);
53
+
54
+ const tileWidth = info.tiles[0].width;
55
+ const tileHeight = info.tiles[0].height || info.tiles[0].width;
56
+
57
+ const cols = Math.ceil(info.width / (tileWidth * scaleFactor));
58
+ const rows = Math.ceil(info.height / (tileHeight * scaleFactor));
59
+
60
+ const tiles = [];
61
+
62
+ for (let y = 0; y < rows; y++) {
63
+ for (let x = 0; x < cols; x++) {
64
+ const actualWidth = Math.min(tileWidth,
65
+ (info.width - x * tileWidth * scaleFactor) / scaleFactor);
66
+
67
+ const actualHeight = Math.min(tileHeight,
68
+ (info.height - y * tileHeight * scaleFactor) / scaleFactor);
69
+
70
+ if (actualWidth <= 0 || actualHeight <= 0) continue;
71
+
72
+ tiles.push({
73
+ url: `${info['@id']}/${x * tileWidth * scaleFactor},${y * tileHeight * scaleFactor},${actualWidth * scaleFactor},${actualHeight * scaleFactor}/${Math.ceil(actualWidth)},/0/default.jpg`,
74
+ width: Math.ceil(actualWidth),
75
+ height: Math.ceil(actualHeight),
76
+ x: x * tileWidth,
77
+ y: y * tileHeight
78
+ });
79
+ }
80
+ }
81
+
82
+ return tiles;
83
+ }
84
+
85
+ export const getThumbnail = async (resource: Level0ImageServiceResource, minSize?: Partial<Size>): Promise<Blob> => {
86
+ const info = await fetch(resource.serviceUrl).then(res => res.json());
87
+
88
+ const tiles = getThumbnailTiles(info, minSize);
89
+ const dimensions = getThumbnailDimensions(info, minSize);
90
+
91
+ const canvas = document.createElement('canvas');
92
+ canvas.width = dimensions.width;
93
+ canvas.height = dimensions.height;
94
+
95
+ const ctx = canvas.getContext('2d');
96
+ if (!ctx)
97
+ throw new Error('Error creating canvas context');
98
+
99
+ const loader = getThrottledLoader();
100
+
101
+ await Promise.all(tiles.map(async (tile) => {
102
+ const img = await loader.loadImage(tile.url);
103
+ ctx.drawImage(img, tile.x, tile.y);
104
+ }));
105
+
106
+ return new Promise((resolve, reject) => {
107
+ canvas.toBlob(blob => {
108
+ if (blob) resolve(blob);
109
+ else reject(new Error('Failed to create blob'));
110
+ }, 'image/jpeg', 0.85);
111
+ });
112
+
113
+ }