openseadragon-capture 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ravi Shankar Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # openseadragon-capture
2
+
3
+ Capture high-quality screenshots from OpenSeadragon viewers with optional overlay layers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install openseadragon-capture
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import OpenSeadragon from 'openseadragon';
15
+ import { createScreenshot } from 'openseadragon-capture';
16
+
17
+ // Initialize OpenSeadragon viewer with CORS support
18
+ const viewer = OpenSeadragon({
19
+ id: 'viewer',
20
+ tileSources: 'path/to/your/image.dzi',
21
+ crossOriginPolicy: 'Anonymous' // Required for screenshot export
22
+ });
23
+
24
+ // Wait for viewer to open
25
+ viewer.addHandler('open', async () => {
26
+ // Create screenshot instance
27
+ const screenshot = createScreenshot(viewer);
28
+
29
+ // Capture as Blob (recommended - memory efficient)
30
+ const blob = await screenshot.toBlob({
31
+ format: 'png',
32
+ quality: 0.9,
33
+ scale: 2
34
+ });
35
+
36
+ // Or capture as data URL
37
+ const dataUrl = await screenshot.capture({
38
+ format: 'png',
39
+ quality: 0.9,
40
+ overlays: [overlayCanvas]
41
+ });
42
+
43
+ // Or download directly
44
+ await screenshot.download('screenshot.png', {
45
+ overlays: [overlayCanvas]
46
+ });
47
+ });
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `createScreenshot(viewer: OpenSeadragon.Viewer): OpenSeadragonScreenshot`
53
+
54
+ Creates a screenshot instance for the given viewer.
55
+
56
+ ### `toBlob(options?: ScreenshotOptions): Promise<Blob>`
57
+
58
+ Captures the viewer as a Blob. Recommended for memory efficiency.
59
+
60
+ **Throws:** Error if viewer is not open or canvas is unavailable.
61
+
62
+ ### `capture(options?: ScreenshotOptions): Promise<string>`
63
+
64
+ Captures the viewer as a data URL.
65
+
66
+ **Throws:** Error if viewer is not open or canvas is unavailable.
67
+
68
+ ### `download(filename: string, options?: ScreenshotOptions): Promise<void>`
69
+
70
+ Captures and downloads the screenshot with the specified filename.
71
+
72
+ ### ScreenshotOptions
73
+
74
+ ```typescript
75
+ export type ScreenshotFormat = 'png' | 'jpeg' | 'webp';
76
+
77
+ interface ScreenshotOptions {
78
+ /** Output image format. @default 'png' */
79
+ format?: ScreenshotFormat;
80
+
81
+ /** Image quality (0-1). @default 0.9 */
82
+ quality?: number;
83
+
84
+ /**
85
+ * Upscaling factor applied via canvas interpolation.
86
+ * Does NOT fetch higher-resolution tiles.
87
+ * @default 1
88
+ */
89
+ scale?: number;
90
+
91
+ /**
92
+ * Overlay canvases to composite.
93
+ * Must already be in viewer pixel space with transforms applied.
94
+ */
95
+ overlays?: HTMLCanvasElement[];
96
+
97
+ /**
98
+ * If true, temporarily fits entire image to viewport before capture.
99
+ * Causes momentary viewport change.
100
+ * @default true
101
+ */
102
+ fitImageToViewport?: boolean;
103
+
104
+ /**
105
+ * Index of the tiled image to capture (for multi-image viewers).
106
+ * @default 0
107
+ */
108
+ imageIndex?: number;
109
+ }
110
+ ```
111
+
112
+ ## Important Limitations
113
+
114
+ ⚠️ **CORS Requirement**: Images must be served with CORS headers. Set `crossOriginPolicy: 'Anonymous'` in viewer config. CORS errors will cause capture to fail.
115
+
116
+ ⚠️ **Scale Behavior**: The `scale` parameter upscales via canvas interpolation. It does NOT fetch higher-resolution tiles. For true high-resolution exports, tiles must be loaded at the target resolution.
117
+
118
+ ⚠️ **Viewport Changes**: `fitImageToViewport: true` temporarily changes the viewport to fit the entire image, which may cause visual flicker.
119
+
120
+ ⚠️ **Overlay Requirements**: Overlay canvases must already be in viewer pixel space with transforms applied. Overlays with mismatched dimensions will be stretched.
121
+
122
+ ⚠️ **Timing**: Capture waits for tiles to load using heuristic-based timing. May fail if tiles load slowly.
123
+
124
+ ⚠️ **Memory**: Large scale factors can create very large canvases (warning shown if exceeding 16MP).
125
+
126
+ ## Production Considerations
127
+
128
+ - Always handle promise rejections (CORS errors are common)
129
+ - Use `toBlob()` instead of `capture()` for better memory management
130
+ - Wait for viewer 'open' event before capturing
131
+ - Test with your specific tile sources and CORS configuration
132
+ - Validate overlay canvas dimensions match viewer canvas
133
+ - Be cautious with large scale factors to avoid memory issues
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OpenSeadragonScreenshot = void 0;
4
+ exports.createScreenshot = createScreenshot;
5
+ class OpenSeadragonScreenshot {
6
+ constructor(viewer) {
7
+ this.viewer = viewer;
8
+ }
9
+ async capture(options = {}) {
10
+ const blob = await this.toBlob(options);
11
+ return new Promise((resolve, reject) => {
12
+ const reader = new FileReader();
13
+ reader.onloadend = () => resolve(reader.result);
14
+ reader.onerror = () => reject(new Error('Failed to convert blob to data URL'));
15
+ reader.readAsDataURL(blob);
16
+ });
17
+ }
18
+ async toBlob(options = {}) {
19
+ this.ensureViewerReady();
20
+ const { format = 'png', quality = 0.9, scale = 1, overlays = [], fitImageToViewport = true, imageIndex = 0 } = options;
21
+ const stage = await this.prepareCapture(scale, overlays, fitImageToViewport, imageIndex);
22
+ return this.renderStage(stage, format, quality);
23
+ }
24
+ ensureViewerReady() {
25
+ if (!this.viewer.isOpen()) {
26
+ throw new Error('[OpenSeadragon Capture] Viewer is not open. Wait for the "open" event before capturing.');
27
+ }
28
+ }
29
+ async prepareCapture(scale, overlays, fitImageToViewport, imageIndex) {
30
+ await this.waitForDraw();
31
+ if (fitImageToViewport) {
32
+ const canvas = await this.captureFullImage(imageIndex);
33
+ return { canvas, overlays, scale };
34
+ }
35
+ const canvas = this.getCanvas();
36
+ return { canvas, overlays, scale };
37
+ }
38
+ getCanvas() {
39
+ const canvas = this.viewer.drawer?.canvas;
40
+ if (!canvas) {
41
+ throw new Error('[OpenSeadragon Capture] Canvas not available. Ensure viewer is fully initialized.');
42
+ }
43
+ return canvas;
44
+ }
45
+ waitForDraw() {
46
+ return new Promise((resolve) => {
47
+ const timeout = setTimeout(() => {
48
+ requestAnimationFrame(() => resolve());
49
+ }, 100);
50
+ this.viewer.addOnceHandler('animation-finish', () => {
51
+ clearTimeout(timeout);
52
+ requestAnimationFrame(() => resolve());
53
+ });
54
+ this.viewer.forceRedraw();
55
+ });
56
+ }
57
+ async captureFullImage(imageIndex) {
58
+ const tiledImage = this.viewer.world.getItemAt(imageIndex);
59
+ if (!tiledImage) {
60
+ throw new Error(`[OpenSeadragon Capture] No image at index ${imageIndex}`);
61
+ }
62
+ const currentBounds = this.viewer.viewport.getBounds();
63
+ const bounds = tiledImage.getBounds();
64
+ try {
65
+ this.viewer.viewport.fitBounds(bounds, true);
66
+ await this.waitForFullLoad(tiledImage);
67
+ return this.getCanvas();
68
+ }
69
+ finally {
70
+ this.viewer.viewport.fitBounds(currentBounds, true);
71
+ }
72
+ }
73
+ waitForFullLoad(tiledImage) {
74
+ if (tiledImage.getFullyLoaded()) {
75
+ return this.waitForDraw();
76
+ }
77
+ return new Promise((resolve) => {
78
+ tiledImage.addOnceHandler('fully-loaded-change', () => {
79
+ this.waitForDraw().then(resolve);
80
+ });
81
+ });
82
+ }
83
+ validateOverlays(canvas, overlays) {
84
+ for (const overlay of overlays) {
85
+ if (overlay.width !== canvas.width || overlay.height !== canvas.height) {
86
+ console.warn(`[OpenSeadragon Capture] Overlay dimensions (${overlay.width}x${overlay.height}) ` +
87
+ `do not match viewer canvas (${canvas.width}x${canvas.height}). ` +
88
+ `Overlay will be stretched.`);
89
+ }
90
+ }
91
+ }
92
+ renderStage(stage, format, quality) {
93
+ return new Promise((resolve, reject) => {
94
+ try {
95
+ const outputCanvas = document.createElement('canvas');
96
+ const outputCtx = outputCanvas.getContext('2d', { alpha: format === 'png' });
97
+ if (!outputCtx) {
98
+ reject(new Error('[OpenSeadragon Capture] Failed to get canvas context'));
99
+ return;
100
+ }
101
+ outputCanvas.width = stage.canvas.width * stage.scale;
102
+ outputCanvas.height = stage.canvas.height * stage.scale;
103
+ const maxPixels = 16777216;
104
+ if (outputCanvas.width * outputCanvas.height > maxPixels) {
105
+ console.warn(`[OpenSeadragon Capture] Output canvas is very large (${outputCanvas.width}x${outputCanvas.height}). ` +
106
+ `This may cause memory issues.`);
107
+ }
108
+ this.validateOverlays(stage.canvas, stage.overlays);
109
+ outputCtx.imageSmoothingEnabled = true;
110
+ outputCtx.imageSmoothingQuality = 'high';
111
+ outputCtx.drawImage(stage.canvas, 0, 0, outputCanvas.width, outputCanvas.height);
112
+ for (const overlay of stage.overlays) {
113
+ if (overlay.width > 0 && overlay.height > 0) {
114
+ outputCtx.drawImage(overlay, 0, 0, outputCanvas.width, outputCanvas.height);
115
+ }
116
+ }
117
+ outputCanvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('[OpenSeadragon Capture] Failed to create blob')), `image/${format}`, quality);
118
+ }
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : 'Unknown error';
121
+ reject(new Error(`[OpenSeadragon Capture] ${message}. Check CORS policy if using remote images.`));
122
+ }
123
+ });
124
+ }
125
+ async download(filename, options = {}) {
126
+ const blob = await this.toBlob(options);
127
+ const url = URL.createObjectURL(blob);
128
+ try {
129
+ const link = document.createElement('a');
130
+ link.download = filename;
131
+ link.href = url;
132
+ link.click();
133
+ }
134
+ finally {
135
+ if (typeof requestIdleCallback === 'undefined') {
136
+ setTimeout(() => URL.revokeObjectURL(url), 100);
137
+ }
138
+ else {
139
+ requestIdleCallback(() => URL.revokeObjectURL(url), { timeout: 1000 });
140
+ }
141
+ }
142
+ }
143
+ }
144
+ exports.OpenSeadragonScreenshot = OpenSeadragonScreenshot;
145
+ function createScreenshot(viewer) {
146
+ return new OpenSeadragonScreenshot(viewer);
147
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createScreenshot = exports.OpenSeadragonScreenshot = void 0;
4
+ var capture_1 = require("./capture");
5
+ Object.defineProperty(exports, "OpenSeadragonScreenshot", { enumerable: true, get: function () { return capture_1.OpenSeadragonScreenshot; } });
6
+ Object.defineProperty(exports, "createScreenshot", { enumerable: true, get: function () { return capture_1.createScreenshot; } });
@@ -0,0 +1,142 @@
1
+ export class OpenSeadragonScreenshot {
2
+ constructor(viewer) {
3
+ this.viewer = viewer;
4
+ }
5
+ async capture(options = {}) {
6
+ const blob = await this.toBlob(options);
7
+ return new Promise((resolve, reject) => {
8
+ const reader = new FileReader();
9
+ reader.onloadend = () => resolve(reader.result);
10
+ reader.onerror = () => reject(new Error('Failed to convert blob to data URL'));
11
+ reader.readAsDataURL(blob);
12
+ });
13
+ }
14
+ async toBlob(options = {}) {
15
+ this.ensureViewerReady();
16
+ const { format = 'png', quality = 0.9, scale = 1, overlays = [], fitImageToViewport = true, imageIndex = 0 } = options;
17
+ const stage = await this.prepareCapture(scale, overlays, fitImageToViewport, imageIndex);
18
+ return this.renderStage(stage, format, quality);
19
+ }
20
+ ensureViewerReady() {
21
+ if (!this.viewer.isOpen()) {
22
+ throw new Error('[OpenSeadragon Capture] Viewer is not open. Wait for the "open" event before capturing.');
23
+ }
24
+ }
25
+ async prepareCapture(scale, overlays, fitImageToViewport, imageIndex) {
26
+ await this.waitForDraw();
27
+ if (fitImageToViewport) {
28
+ const canvas = await this.captureFullImage(imageIndex);
29
+ return { canvas, overlays, scale };
30
+ }
31
+ const canvas = this.getCanvas();
32
+ return { canvas, overlays, scale };
33
+ }
34
+ getCanvas() {
35
+ const canvas = this.viewer.drawer?.canvas;
36
+ if (!canvas) {
37
+ throw new Error('[OpenSeadragon Capture] Canvas not available. Ensure viewer is fully initialized.');
38
+ }
39
+ return canvas;
40
+ }
41
+ waitForDraw() {
42
+ return new Promise((resolve) => {
43
+ const timeout = setTimeout(() => {
44
+ requestAnimationFrame(() => resolve());
45
+ }, 100);
46
+ this.viewer.addOnceHandler('animation-finish', () => {
47
+ clearTimeout(timeout);
48
+ requestAnimationFrame(() => resolve());
49
+ });
50
+ this.viewer.forceRedraw();
51
+ });
52
+ }
53
+ async captureFullImage(imageIndex) {
54
+ const tiledImage = this.viewer.world.getItemAt(imageIndex);
55
+ if (!tiledImage) {
56
+ throw new Error(`[OpenSeadragon Capture] No image at index ${imageIndex}`);
57
+ }
58
+ const currentBounds = this.viewer.viewport.getBounds();
59
+ const bounds = tiledImage.getBounds();
60
+ try {
61
+ this.viewer.viewport.fitBounds(bounds, true);
62
+ await this.waitForFullLoad(tiledImage);
63
+ return this.getCanvas();
64
+ }
65
+ finally {
66
+ this.viewer.viewport.fitBounds(currentBounds, true);
67
+ }
68
+ }
69
+ waitForFullLoad(tiledImage) {
70
+ if (tiledImage.getFullyLoaded()) {
71
+ return this.waitForDraw();
72
+ }
73
+ return new Promise((resolve) => {
74
+ tiledImage.addOnceHandler('fully-loaded-change', () => {
75
+ this.waitForDraw().then(resolve);
76
+ });
77
+ });
78
+ }
79
+ validateOverlays(canvas, overlays) {
80
+ for (const overlay of overlays) {
81
+ if (overlay.width !== canvas.width || overlay.height !== canvas.height) {
82
+ console.warn(`[OpenSeadragon Capture] Overlay dimensions (${overlay.width}x${overlay.height}) ` +
83
+ `do not match viewer canvas (${canvas.width}x${canvas.height}). ` +
84
+ `Overlay will be stretched.`);
85
+ }
86
+ }
87
+ }
88
+ renderStage(stage, format, quality) {
89
+ return new Promise((resolve, reject) => {
90
+ try {
91
+ const outputCanvas = document.createElement('canvas');
92
+ const outputCtx = outputCanvas.getContext('2d', { alpha: format === 'png' });
93
+ if (!outputCtx) {
94
+ reject(new Error('[OpenSeadragon Capture] Failed to get canvas context'));
95
+ return;
96
+ }
97
+ outputCanvas.width = stage.canvas.width * stage.scale;
98
+ outputCanvas.height = stage.canvas.height * stage.scale;
99
+ const maxPixels = 16777216;
100
+ if (outputCanvas.width * outputCanvas.height > maxPixels) {
101
+ console.warn(`[OpenSeadragon Capture] Output canvas is very large (${outputCanvas.width}x${outputCanvas.height}). ` +
102
+ `This may cause memory issues.`);
103
+ }
104
+ this.validateOverlays(stage.canvas, stage.overlays);
105
+ outputCtx.imageSmoothingEnabled = true;
106
+ outputCtx.imageSmoothingQuality = 'high';
107
+ outputCtx.drawImage(stage.canvas, 0, 0, outputCanvas.width, outputCanvas.height);
108
+ for (const overlay of stage.overlays) {
109
+ if (overlay.width > 0 && overlay.height > 0) {
110
+ outputCtx.drawImage(overlay, 0, 0, outputCanvas.width, outputCanvas.height);
111
+ }
112
+ }
113
+ outputCanvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('[OpenSeadragon Capture] Failed to create blob')), `image/${format}`, quality);
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : 'Unknown error';
117
+ reject(new Error(`[OpenSeadragon Capture] ${message}. Check CORS policy if using remote images.`));
118
+ }
119
+ });
120
+ }
121
+ async download(filename, options = {}) {
122
+ const blob = await this.toBlob(options);
123
+ const url = URL.createObjectURL(blob);
124
+ try {
125
+ const link = document.createElement('a');
126
+ link.download = filename;
127
+ link.href = url;
128
+ link.click();
129
+ }
130
+ finally {
131
+ if (typeof requestIdleCallback === 'undefined') {
132
+ setTimeout(() => URL.revokeObjectURL(url), 100);
133
+ }
134
+ else {
135
+ requestIdleCallback(() => URL.revokeObjectURL(url), { timeout: 1000 });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ export function createScreenshot(viewer) {
141
+ return new OpenSeadragonScreenshot(viewer);
142
+ }
@@ -0,0 +1 @@
1
+ export { OpenSeadragonScreenshot, createScreenshot } from './capture';
@@ -0,0 +1,26 @@
1
+ import OpenSeadragon from 'openseadragon';
2
+ export type ScreenshotFormat = 'png' | 'jpeg' | 'webp';
3
+ export interface ScreenshotOptions {
4
+ format?: ScreenshotFormat;
5
+ quality?: number;
6
+ scale?: number;
7
+ overlays?: HTMLCanvasElement[];
8
+ fitImageToViewport?: boolean;
9
+ imageIndex?: number;
10
+ }
11
+ export declare class OpenSeadragonScreenshot {
12
+ private readonly viewer;
13
+ constructor(viewer: OpenSeadragon.Viewer);
14
+ capture(options?: ScreenshotOptions): Promise<string>;
15
+ toBlob(options?: ScreenshotOptions): Promise<Blob>;
16
+ private ensureViewerReady;
17
+ private prepareCapture;
18
+ private getCanvas;
19
+ private waitForDraw;
20
+ private captureFullImage;
21
+ private waitForFullLoad;
22
+ private validateOverlays;
23
+ private renderStage;
24
+ download(filename: string, options?: ScreenshotOptions): Promise<void>;
25
+ }
26
+ export declare function createScreenshot(viewer: OpenSeadragon.Viewer): OpenSeadragonScreenshot;
@@ -0,0 +1,2 @@
1
+ export { OpenSeadragonScreenshot, createScreenshot } from './capture';
2
+ export type { ScreenshotOptions, ScreenshotFormat } from './capture';
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "openseadragon-capture",
3
+ "version": "1.0.0",
4
+ "description": "Capture high-quality screenshots from OpenSeadragon viewers with optional overlay layers.",
5
+ "type": "module",
6
+ "main": "dist/cjs/index.js",
7
+ "module": "dist/esm/index.js",
8
+ "types": "dist/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/esm/index.js",
12
+ "require": "./dist/cjs/index.js"
13
+ }
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "workspaces": [
24
+ "site",
25
+ "tests"
26
+ ],
27
+ "scripts": {
28
+ "clean": "node ./scripts/clean.mjs",
29
+ "build": "npm run build:dual",
30
+ "build:dual": "npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json",
31
+ "dev": "npm run -w site dev",
32
+ "build:demo": "npm run build && npm run -w site build",
33
+ "deploy:demo": "npm run build && npm run build:demo && git subtree push --prefix site/dist origin gh-pages",
34
+ "test": "npm run -w tests test",
35
+ "test:run": "npm run -w tests test:run",
36
+ "prepare": "npm run build"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/rssaini01/openseadragon-capture.git"
41
+ },
42
+ "keywords": [
43
+ "openseadragon",
44
+ "canvas",
45
+ "overlay",
46
+ "screenshot",
47
+ "export"
48
+ ],
49
+ "author": "Ravi Shankar Saini",
50
+ "license": "MIT",
51
+ "bugs": {
52
+ "url": "https://github.com/rssaini01/openseadragon-capture/issues"
53
+ },
54
+ "homepage": "https://github.com/rssaini01/openseadragon-capture/",
55
+ "devDependencies": {
56
+ "@types/openseadragon": "^5.0.1",
57
+ "openseadragon": "^5.0.1",
58
+ "typescript": "^5.9.3"
59
+ }
60
+ }