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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/Cozy.d.ts +4 -0
- package/dist/core/canvas.d.ts +5 -0
- package/dist/core/image-service.d.ts +12 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/resource.d.ts +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/dist/level-0/crop-region.d.ts +2 -0
- package/dist/level-0/get-thumbnail.d.ts +3 -0
- package/dist/level-0/index.d.ts +2 -0
- package/dist/level-0/index.js +6 -0
- package/dist/level-0/throttled-loader.d.ts +7 -0
- package/dist/level-0/types.d.ts +22 -0
- package/dist/src/Cozy.js +133 -0
- package/dist/src/core/canvas.js +63 -0
- package/dist/src/core/image-service.js +36 -0
- package/dist/src/core/resource.js +28 -0
- package/dist/src/level-0/crop-region.js +59 -0
- package/dist/src/level-0/get-thumbnail.js +57 -0
- package/dist/src/level-0/throttled-loader.js +33 -0
- package/dist/types.d.ts +86 -0
- package/package.json +37 -0
- package/src/Cozy.ts +182 -0
- package/src/core/canvas.ts +107 -0
- package/src/core/image-service.ts +103 -0
- package/src/core/index.ts +3 -0
- package/src/core/resource.ts +41 -0
- package/src/index.ts +3 -0
- package/src/level-0/crop-region.ts +109 -0
- package/src/level-0/get-thumbnail.ts +113 -0
- package/src/level-0/index.ts +2 -0
- package/src/level-0/throttled-loader.ts +85 -0
- package/src/level-0/types.ts +37 -0
- package/src/types.ts +137 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +21 -0
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,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,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
|
+
}
|