From 4f79f2923843e3cea7048851a76671f5c02a7be8 Mon Sep 17 00:00:00 2001 From: Jirka Date: Sat, 16 Apr 2022 21:19:54 +0200 Subject: [PATCH] Move cache creation, image downloading process and transparency deduction to the TileSource instance to allow custom data fetching, caching, processing. --- src/drawer.js | 5 +- src/imageloader.js | 93 ++++------------------ src/tile.js | 64 +++++++++++---- src/tilecache.js | 54 ++++++------- src/tiledimage.js | 14 ++-- src/tilesource.js | 160 ++++++++++++++++++++++++++++++++++++++ test/modules/tilecache.js | 38 ++++++++- 7 files changed, 294 insertions(+), 134 deletions(-) diff --git a/src/drawer.js b/src/drawer.js index 30d7fcf2..43a7c4a7 100644 --- a/src/drawer.js +++ b/src/drawer.js @@ -347,15 +347,16 @@ $.Drawer.prototype = { * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round * position and size of tiles supporting alpha channel in non-transparency * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. */ - drawTile: function(tile, drawingHandler, useSketch, scale, translate, shouldRoundPositionAndSize) { + drawTile: function( tile, drawingHandler, useSketch, scale, translate, shouldRoundPositionAndSize, source) { $.console.assert(tile, '[Drawer.drawTile] tile is required'); $.console.assert(drawingHandler, '[Drawer.drawTile] drawingHandler is required'); if (this.useCanvas) { var context = this._getContext(useSketch); scale = scale || 1; - tile.drawCanvas(context, drawingHandler, scale, translate, shouldRoundPositionAndSize); + tile.drawCanvas(context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source); } else { tile.drawHTML( this.canvas ); } diff --git a/src/imageloader.js b/src/imageloader.js index ac36d47a..451fd60d 100644 --- a/src/imageloader.js +++ b/src/imageloader.js @@ -40,6 +40,7 @@ * @classdesc Handles downloading of a single image. * @param {Object} options - Options for this ImageJob. * @param {String} [options.src] - URL of image to download. + * @param {TileSource} [options.source] - Image loading strategy * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX. * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {String} [options.crossOriginPolicy] - CORS policy to use for downloads @@ -58,7 +59,7 @@ function ImageJob (options) { /** * Image object which will contain downloaded image. - * @member {Image} image + * @member {Image|*} image element (default) or other form of image data (depends on TileSource) * @memberof OpenSeadragon.ImageJob# */ this.image = null; @@ -71,93 +72,29 @@ ImageJob.prototype = { * Starts the image job. * @method */ - start: function(){ + start: function() { var self = this; var selfAbort = this.abort; - this.image = new Image(); - - this.image.onload = function(){ - self.finish(true); - }; - this.image.onabort = this.image.onerror = function() { - self.errorMsg = "Image load aborted"; - self.finish(false); - }; - - this.jobId = window.setTimeout(function(){ + this.jobId = window.setTimeout(function () { self.errorMsg = "Image load exceeded timeout (" + self.timeout + " ms)"; self.finish(false); }, this.timeout); - // Load the tile with an AJAX request if the loadWithAjax option is - // set. Otherwise load the image by setting the source proprety of the image object. - if (this.loadWithAjax) { - this.request = $.makeAjaxRequest({ - url: this.src, - withCredentials: this.ajaxWithCredentials, - headers: this.ajaxHeaders, - responseType: "arraybuffer", - postData: this.postData, - success: function(request) { - var blb; - // Make the raw data into a blob. - // BlobBuilder fallback adapted from - // http://stackoverflow.com/questions/15293694/blob-constructor-browser-compatibility - try { - blb = new window.Blob([request.response]); - } catch (e) { - var BlobBuilder = ( - window.BlobBuilder || - window.WebKitBlobBuilder || - window.MozBlobBuilder || - window.MSBlobBuilder - ); - if (e.name === 'TypeError' && BlobBuilder) { - var bb = new BlobBuilder(); - bb.append(request.response); - blb = bb.getBlob(); - } - } - // If the blob is empty for some reason consider the image load a failure. - if (blb.size === 0) { - self.errorMsg = "Empty image response."; - self.finish(false); - } - // Create a URL for the blob data and make it the source of the image object. - // This will still trigger Image.onload to indicate a successful tile load. - var url = (window.URL || window.webkitURL).createObjectURL(blb); - self.image.src = url; - }, - error: function(request) { - self.errorMsg = "Image load aborted - XHR error: Ajax returned " + request.status; - self.finish(false); - } - }); - - // Provide a function to properly abort the request. - this.abort = function() { - self.request.abort(); - - // Call the existing abort function if available - if (typeof selfAbort === "function") { - selfAbort(); - } - }; - } else { - if (this.crossOriginPolicy !== false) { - this.image.crossOrigin = this.crossOriginPolicy; + this.abort = function() { + self.request.abort(); + if (typeof selfAbort === "function") { + selfAbort(); } + }; - this.image.src = this.src; - } + this.source.downloadTileStart(this); }, - finish: function(successful) { - this.image.onload = this.image.onerror = this.image.onabort = null; - if (!successful) { - this.image = null; - } + finish: function(successful, errorMessage) { + this.errorMsg = errorMessage; + //consider deprecation of .image attribute + this.image = this.source.downloadTileFinish(this, successful); if (this.jobId) { window.clearTimeout(this.jobId); @@ -196,6 +133,7 @@ $.ImageLoader.prototype = { * @method * @param {Object} options - Options for this job. * @param {String} [options.src] - URL of image to download. + * @param {TileSource} [options.source] - Image loading strategy * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX. * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {String|Boolean} [options.crossOriginPolicy] - CORS policy to use for downloads @@ -213,6 +151,7 @@ $.ImageLoader.prototype = { }, jobOptions = { src: options.src, + source: options.source, loadWithAjax: options.loadWithAjax, ajaxHeaders: options.loadWithAjax ? options.ajaxHeaders : null, crossOriginPolicy: options.crossOriginPolicy, diff --git a/src/tile.js b/src/tile.js index 614827ac..e09a0b59 100644 --- a/src/tile.js +++ b/src/tile.js @@ -82,11 +82,11 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja */ this.bounds = bounds; /** - * The portion of the tile to use as the source of the drawing operation, in pixels. Note that - * this only works when drawing with canvas; when drawing with HTML the entire tile is always used. - * @member {OpenSeadragon.Rect} sourceBounds - * @memberof OpenSeadragon.Tile# - */ + * The portion of the tile to use as the source of the drawing operation, in pixels. Note that + * this only works when drawing with canvas; when drawing with HTML the entire tile is always used. + * @member {OpenSeadragon.Rect} sourceBounds + * @memberof OpenSeadragon.Tile# + */ this.sourceBounds = sourceBounds; /** * Is this tile a part of a sparse image? Also has this tile failed to load? @@ -127,18 +127,18 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.ajaxHeaders = ajaxHeaders; - /** - * The unique cache key for this tile. - * @member {String} cacheKey - * @memberof OpenSeadragon.Tile# - */ + if (cacheKey === undefined) { $.console.error("Tile constructor needs 'cacheKey' variable: creation tile cache" + " in Tile class is deprecated. TileSource.prototype.getTileHashKey will be used."); cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData); } + /** + * The unique cache key for this tile. + * @member {String} cacheKey + * @memberof OpenSeadragon.Tile# + */ this.cacheKey = cacheKey; - /** * Is this tile loaded? * @member {Boolean} loaded @@ -266,6 +266,8 @@ $.Tile.prototype = { // private _hasTransparencyChannel: function() { + console.warn("Tile.prototype._hasTransparencyChannel has been " + + "deprecated and will be removed in the future."); return !!this.context2D || this.url.match('.png'); }, @@ -295,7 +297,7 @@ $.Tile.prototype = { if ( !this.element ) { this.element = $.makeNeutralElement( "div" ); - this.imgElement = this.cacheImageRecord.getImage().cloneNode(); + this.imgElement = this.imageData(); this.imgElement.style.msInterpolationMode = "nearest-neighbor"; this.imgElement.style.width = "100%"; this.imgElement.style.height = "100%"; @@ -322,6 +324,26 @@ $.Tile.prototype = { $.setElementOpacity( this.element, this.opacity ); }, + /** + * Get the tile image data as element if + * supported + * + * @return {Image || undefined} + */ + imageData: function() { + return this.image || this.cacheImageRecord.getImage(); + }, + + /** + * Get the CanvasRenderingContext2D instance for tile image data drawn + * onto Canvas if supported + * + * @return {CanvasRenderingContext2D || undefined} + */ + canvasContext: function() { + return this.context2D || this.cacheImageRecord.getRenderedContext(); + }, + /** * Renders the tile in a canvas-based context. * @function @@ -334,12 +356,14 @@ $.Tile.prototype = { * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round * position and size of tiles supporting alpha channel in non-transparency * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. */ - drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize ) { + drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) { var position = this.position.times($.pixelDensityRatio), size = this.size.times($.pixelDensityRatio), - rendered; + rendered, + hasTransparency; if (!this.context2D && !this.cacheImageRecord) { $.console.warn( @@ -348,7 +372,7 @@ $.Tile.prototype = { return; } - rendered = this.context2D || this.cacheImageRecord.getRenderedContext(); + rendered = this.canvasContext(); if ( !this.loaded || !rendered ){ $.console.warn( @@ -374,11 +398,19 @@ $.Tile.prototype = { position = position.plus(translate); } + if (source === undefined) { + $.console.warn('[Tile.drawCanvas] deprecated call without argument \'hasTransparency\'.'); + hasTransparency = context.globalAlpha === 1 && this._hasTransparencyChannel(); + } else { + hasTransparency = context.globalAlpha === 1 && + source.hasTransparency(this.context2D, this.url, this.ajaxHeaders, this.postData); + } + //if we are supposed to be rendering fully opaque rectangle, //ie its done fading or fading is turned off, and if we are drawing //an image with an alpha channel, then the only way //to avoid seeing the tile underneath is to clear the rectangle - if (context.globalAlpha === 1 && this._hasTransparencyChannel()) { + if (hasTransparency) { if (shouldRoundPositionAndSize) { // Round to the nearest whole pixel so we don't get seams from overlap. position.x = Math.round(position.x); diff --git a/src/tilecache.js b/src/tilecache.js index b0745eb1..199e1f80 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -45,43 +45,31 @@ var TileRecord = function( options ) { // private class var ImageRecord = function(options) { + //private scope: changed image -> data $.console.assert( options, "[ImageRecord] options is required" ); - $.console.assert( options.image, "[ImageRecord] options.image is required" ); - this._image = options.image; + $.console.assert( options.data, "[ImageRecord] options.data is required" ); this._tiles = []; + + options.create.apply(null, [this, options.data, options.ownerTile]); + this._destroyImplementation = options.destroy.bind(null, this); + this.getImage = options.getImage.bind(null, this); + this.getData = options.getData.bind(null, this); + this.getRenderedContext = options.getRenderedContext.bind(null, this); }; ImageRecord.prototype = { destroy: function() { - this._image = null; - this._renderedContext = null; + this._destroyImplementation(); this._tiles = null; }, - getImage: function() { - return this._image; - }, - - getRenderedContext: function() { - if (!this._renderedContext) { - var canvas = document.createElement( 'canvas' ); - canvas.width = this._image.width; - canvas.height = this._image.height; - this._renderedContext = canvas.getContext('2d'); - this._renderedContext.drawImage( this._image, 0, 0 ); - //since we are caching the prerendered image on a canvas - //allow the image to not be held in memory - this._image = null; - } - return this._renderedContext; - }, - - setRenderedContext: function(renderedContext) { - $.console.error("ImageRecord.setRenderedContext is deprecated. " + - "The rendered context should be created by the ImageRecord " + - "itself when calling ImageRecord.getRenderedContext."); - this._renderedContext = renderedContext; - }, + // Removed (left as a comment so that it stands out) + // setRenderedContext: function(renderedContext) { + // $.console.error("ImageRecord.setRenderedContext is deprecated. " + + // "The rendered context should be created by the ImageRecord " + + // "itself when calling ImageRecord.getRenderedContext."); + // this._renderedContext = renderedContext; + // }, addTile: function(tile) { $.console.assert(tile, '[ImageRecord.addTile] tile is required'); @@ -160,7 +148,13 @@ $.TileCache.prototype = { if (!imageRecord) { $.console.assert( options.image, "[TileCache.cacheTile] options.image is required to create an ImageRecord" ); imageRecord = this._imagesLoaded[options.tile.cacheKey] = new ImageRecord({ - image: options.image + data: options.image, + ownerTile: options.tile, + create: options.tiledImage.source.createTileCache, + destroy: options.tiledImage.source.destroyTileCache, + getImage: options.tiledImage.source.getTileCacheDataAsImage, + getData: options.tiledImage.source.getTileCacheData, + getRenderedContext: options.tiledImage.source.getTileCacheDataAsContext2D, }); this._imagesLoadedCount++; @@ -196,7 +190,7 @@ $.TileCache.prototype = { worstLevel = worstTile.level; if ( prevTime < worstTime || - ( prevTime === worstTime && prevLevel > worstLevel ) ) { + ( prevTime === worstTime && prevLevel > worstLevel ) ) { worstTile = prevTile; worstTileIndex = i; worstTileRecord = prevTileRecord; diff --git a/src/tiledimage.js b/src/tiledimage.js index 761eb9b0..86e53357 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -1427,8 +1427,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } else { var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); if (imageRecord) { - var image = imageRecord.getImage(); - this._setTileLoaded(tile, image); + this._setTileLoaded(tile, imageRecord.getData()); } } } @@ -1571,6 +1570,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag tile.loading = true; this._imageLoader.addJob({ src: tile.url, + source: this.source, postData: tile.postData, loadWithAjax: tile.loadWithAjax, ajaxHeaders: tile.ajaxHeaders, @@ -1649,7 +1649,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @private * @inner * @param {OpenSeadragon.Tile} tile - * @param {Image || undefined} image + * @param {Image|* || undefined} image * @param {Number || undefined} cutoff * @param {XMLHttpRequest || undefined} tileRequest */ @@ -1686,7 +1686,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @event tile-loaded * @memberof OpenSeadragon.Viewer * @type {object} - * @property {Image} image - The image of the tile. + * @property {Image|*} image - The image (data) of the tile. * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). @@ -1842,7 +1842,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag if (tile) { useSketch = this.opacity < 1 || (this.compositeOperation && this.compositeOperation !== 'source-over') || - (!this._isBottomItem() && tile._hasTransparencyChannel()); + (!this._isBottomItem() && + this.source.hasTransparency(tile.context2D, tile.url, tile.ajaxHeaders, tile.postData)); } var sketchScale; @@ -1984,7 +1985,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag for (var i = lastDrawn.length - 1; i >= 0; i--) { tile = lastDrawn[ i ]; - this._drawer.drawTile( tile, this._drawingHandler, useSketch, sketchScale, sketchTranslate, shouldRoundPositionAndSize ); + this._drawer.drawTile( tile, this._drawingHandler, useSketch, sketchScale, + sketchTranslate, shouldRoundPositionAndSize, this.source ); tile.beingDrawn = true; if( this.viewer ){ diff --git a/src/tilesource.js b/src/tilesource.js index 797ce714..4c9688ef 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -709,6 +709,166 @@ $.TileSource.prototype = { y >= 0 && x < numTiles.x && y < numTiles.y; + }, + + /** + * Decide whether tiles have transparency: this is crucial for + * @return {boolean} true if the image has transparency + */ + hasTransparency: function(context2D, url, ajaxHeaders, post) { + return !!context2D || url.match('.png'); + }, + + /** + * Download tile data + * @param {object} context job context that you have to call finish(...) on. It also contains abort(...) function + * that can be called to abort the job. + * @param {String} [context.src] - URL of image to download. + * @param {String} [context.loadWithAjax] - Whether to load this image with AJAX. + * @param {String} [context.ajaxHeaders] - Headers to add to the image request if using AJAX. + * @param {String} [context.crossOriginPolicy] - CORS policy to use for downloads + * @param {String} [context.postData] - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, + * see TileSrouce::getPostData) or null + * @param {Function} [context.callback] - Called once image has been downloaded. + * @param {Function} [context.abort] - Called when this image job is aborted. + * @param {Number} [context.timeout] - The max number of milliseconds that this image job may take to complete. + */ + downloadTileStart: function (context) { + context.image = new Image(); + + context.image.onload = function(){ + context.finish(true); + }; + context.image.onabort = context.image.onerror = function() { + context.finish(false, "Image load aborted"); + }; + + // Load the tile with an AJAX request if the loadWithAjax option is + // set. Otherwise load the image by setting the source proprety of the image object. + if (context.loadWithAjax) { + context.request = $.makeAjaxRequest({ + url: context.src, + withCredentials: context.ajaxWithCredentials, + headers: context.ajaxHeaders, + responseType: "arraybuffer", + postData: context.postData, + success: function(request) { + var blb; + // Make the raw data into a blob. + // BlobBuilder fallback adapted from + // http://stackoverflow.com/questions/15293694/blob-constructor-browser-compatibility + try { + blb = new window.Blob([request.response]); + } catch (e) { + var BlobBuilder = ( + window.BlobBuilder || + window.WebKitBlobBuilder || + window.MozBlobBuilder || + window.MSBlobBuilder + ); + if (e.name === 'TypeError' && BlobBuilder) { + var bb = new BlobBuilder(); + bb.append(request.response); + blb = bb.getBlob(); + } + } + // If the blob is empty for some reason consider the image load a failure. + if (blb.size === 0) { + context.finish(false, "Empty image response."); + } else { + // Create a URL for the blob data and make it the source of the image object. + // This will still trigger Image.onload to indicate a successful tile load. + context.image.src = (window.URL || window.webkitURL).createObjectURL(blb); + } + }, + error: function(request) { + context.finish(false, "Image load aborted - XHR error"); + } + }); + } else { + if (context.crossOriginPolicy !== false) { + context.image.crossOrigin = context.crossOriginPolicy; + } + + context.image.src = context.src; + } + }, + + /** + * @param {object} context job, the same object as with downloadTileStart(..) + * @param successful true if successful + * @return {null|*} null to indicate missing data or data object + * for example, can return default value if the request was unsuccessful such as default error image + */ + downloadTileFinish: function (context, successful) { + if (!context.image) { + return null; + } + context.image.onload = context.image.onerror = context.image.onabort = null; + if (!successful) { + return null; + } + return context.image; + }, + + /** + * Create cache object from the result of the download process + * @param {Tile} tile instance the cache was created with + * @param {object} cacheObject context cache object + * @param {*} data the result of downloadTileFinish() function + */ + createTileCache: function(cacheObject, data, tile) { + cacheObject._data = data; + }, + + /** + * Cache object destructor, unset all properties to allow GC collection. + * @param {object} cacheObject context cache object + */ + destroyTileCache: function (cacheObject) { + cacheObject._data = null; + cacheObject._renderedContext = null; + }, + + /** + * Raw data getter + * @param {object} cacheObject context cache object + * @return {*} cache data + */ + getTileCacheData: function(cacheObject) { + return cacheObject._data; + }, + + /** + * Compatibility image element getter + * - plugins might need image representation of the data + * - div HTML rendering relies on image element presence + * @param {object} cacheObject context cache object + * @return {Image} cache data as an Image + */ + getTileCacheDataAsImage: function(cacheObject) { + return cacheObject._data; //the data itself by default is Image + }, + + /** + * Compatibility context 2D getter + * - most heavily used rendering method is a canvas-based approach, + * convert the data to a canvas and return it's 2D context + * @param {object} cacheObject context cache object + * @return {CanvasRenderingContext2D} context of the canvas representation of the cache data + */ + getTileCacheDataAsContext2D: function(cacheObject) { + if (!cacheObject._renderedContext) { + var canvas = document.createElement( 'canvas' ); + canvas.width = cacheObject._data.width; + canvas.height = cacheObject._data.height; + cacheObject._renderedContext = canvas.getContext('2d'); + cacheObject._renderedContext.drawImage( cacheObject._data, 0, 0 ); + //since we are caching the prerendered image on a canvas + //allow the image to not be held in memory + cacheObject._data = null; + } + return cacheObject._renderedContext; } }; diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index 65fbf431..b933f3d1 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -2,6 +2,35 @@ (function() { + var tileSourceCacheAPI = { + createTileCache: function(cacheObject, data, tile) { + cacheObject._data = data; + }, + destroyTileCache: function (cacheObject) { + cacheObject._data = null; + cacheObject._renderedContext = null; + }, + getTileCacheData: function(cacheObject) { + return cacheObject._data; + }, + getTileCacheDataAsImage: function(cacheObject) { + return cacheObject._data; //the data itself by default is Image + }, + getTileCacheDataAsContext2D: function(cacheObject) { + if (!cacheObject._renderedContext) { + var canvas = document.createElement( 'canvas' ); + canvas.width = cacheObject._data.width; + canvas.height = cacheObject._data.height; + cacheObject._renderedContext = canvas.getContext('2d'); + cacheObject._renderedContext.drawImage( cacheObject._data, 0, 0 ); + //since we are caching the prerendered image on a canvas + //allow the image to not be held in memory + cacheObject._data = null; + } + return cacheObject._renderedContext; + } + }; + // ---------- QUnit.module('TileCache', { beforeEach: function () { @@ -19,10 +48,12 @@ raiseEvent: function() {} }; var fakeTiledImage0 = { - viewer: fakeViewer + viewer: fakeViewer, + source: tileSourceCacheAPI }; var fakeTiledImage1 = { - viewer: fakeViewer + viewer: fakeViewer, + source: tileSourceCacheAPI }; var fakeTile0 = { @@ -74,7 +105,8 @@ raiseEvent: function() {} }; var fakeTiledImage0 = { - viewer: fakeViewer + viewer: fakeViewer, + source: tileSourceCacheAPI }; var fakeTile0 = {