Source: /src/externaldziexttilesource.js

// noinspection JSUnresolvedVariable

/*
 * OpenSeadragon - ExtendedDziTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2013 OpenSeadragon contributors
 * Copyright (C) 2021 RationAI Research Group (Modifications)
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ) {

/**
 * @class ExtendedDziTileSource
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {object} options configuration, output object of configureFromObject()
 * @property {String} tilesUrl
 * @property {String} fileFormat
 */

$.ExtendedDziTileSource = class extends $.TileSource {

    constructor(options) {
        super(options);

        var i,
            rect,
            level;

        this._levelRects = {};
        this.tilesUrl = options.tilesUrl;
        this.fileFormat = options.fileFormat;
        this.displayRects = options.displayRects;

        if (this.displayRects) {
            for (i = this.displayRects.length - 1; i >= 0; i--) {
                rect = this.displayRects[i];
                for (level = rect.minLevel; level <= rect.maxLevel; level++) {
                    if (!this._levelRects[level]) {
                        this._levelRects[level] = [];
                    }
                    this._levelRects[level].push(rect);
                }
            }
        }

        if (!this.fileFormat) this.fileFormat = ".jpg";
        if (!this.greyscale) this.greyscale = "";
    }


    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @param {(Object|Array)} data
     * @param {String} url
     */
    supports( data, url ){
        var ns;
        if ( data.ImageArray ) {
            ns = data.ImageArray.xmlns;
        } else if ( data.documentElement ) {
            if ("ImageArray" == data.documentElement.localName || "ImageArray" == data.documentElement.tagName) {
                ns = data.documentElement.namespaceURI;
            }
        }
        ns = ns || "";
        return ns.indexOf('rationai.fi.muni.cz/deepzoom/images') !== -1;
    }

    /**
     * TODO!!!! this is not tileSource but tiledImage!!!
     *    in TiledImage:
     *             options = $TileSource.prototype.configure.apply( _this, [ data, url, postData ]);
     * @function
     * @param {(Object|XMLDocument)} data - the raw configuration
     * @param {String} url - the url the data was retrieved from if any.
     * @param {String} postData - data for the post request or null
     * @return {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure( data, url, postData ){

        var options = $.isPlainObject(data) ? configureFromObject(this, data) : configureFromXML(this, data);

        //little hack: if we ask for non-pyramidal data (jpg, png), overwrite level, we use post: the query contains link
        const targetUrl = typeof postData === "string" ? postData : url;
        if (targetUrl.endsWith("jpg.dzi") || targetUrl.endsWith("png.dzi") || targetUrl.endsWith("jpeg.dzi")) {
            options.maxLevel = options.minLevel = 0;
        }

        if (postData) {
            options.postData = postData.replace(/([^\/]+?)(\.(dzi|xml|js)?(\?[^\/]*)?)?\/?$/, '$1_files/');
        } else if (url) {
            url = url.replace(
                /([^\/]+?)(\.(dzi|xml|js)?(\?[^\/]*)?)?\/?$/, '$1_files/');
        }

        if (url && !options.tilesUrl) {
            options.tilesUrl = url;
            if (url.search(/\.(dzi|xml|js)\?/) != -1) {
                options.queryParams = url.match(/\?.*/);
            }else{
                options.queryParams = '';
            }
        }
        return options;
    }

    /**
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @return {string}
     */
    getTileUrl( level, x, y ) {
        return this.getUrl(level, x, y);
    }

    /**
     * More generic for other approaches
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @param {String} tiles optionally, provide tiles URL
     * @return {string}
     */
    getUrl( level, x, y, tiles=this.tilesUrl ) {
        return this.postData ? `${tiles}${this.queryParams}`
            : `${tiles}${level}/${x}_${y}.${this.fileFormat}${this.greyscale}${this.queryParams}`;
    }

    /**
     * Responsible for retrieving the headers which will be attached to the image request for the
     * region specified by the given x, y, and level components.
     * This option is only relevant if {@link OpenSeadragon.Options}.loadTilesWithAjax is set to true.
     * The headers returned here will override headers specified at the Viewer or TiledImage level.
     * Specifying a falsy value for a header will clear its existing value set at the Viewer or
     * TiledImage level (if any).
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @returns {Object}
     */
    getTileAjaxHeaders( level, x, y ) {
        return {'Content-type': 'application/x-www-form-urlencoded'};
    }

    /**
     * Must use AJAX in order to work, i.e. loadTilesWithAjax : true is set.
     * It should return url-encoded string with the following structure:
     *   key=value&key2=value2...
     * or null in case GET is used instead.
     * @param level
     * @param x
     * @param y
     * @return {string|null} post data to send with tile configuration request
     */
    getTilePostData(level, x, y) {
        return this.getPostData(level, x, y, this.postData);
    }

    /**
     * More general implementation of post data construction
     * @param level
     * @param x
     * @param y
     * @param data
     * @return {string|null} post data to send with tile configuration request
     */
    getPostData(level, x, y, data) {
        return data ? `${data}${level}/${x}_${y}.${this.fileFormat}${this.greyscale}` : null;
    }

    /**
     * Retrieve image metadata for given image index - tilesources can fetch data or data-arrays.
     * @param index index of the data if tilesource supports multi data fetching
     * @return {TileSourceMetadata}
     */
    getImageMetaAt(index) {
        //not really compatible type, but carries over the error property
        return this.ImageArray[index];
    }

    setFormat(format) {
        this.fileFormat = format;

        let blackImage = (context, resolve, reject) => {
            const canvas = document.createElement('canvas');
            canvas.width = context.getTileWidth();
            canvas.height = context.getTileHeight();
            const ctx = canvas.getContext('2d');
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            const img = new Image(canvas.width, canvas.height);
            img.onload = () => {
                //next promise just returns the created object
                blackImage = (context, ready, _) => ready(img);
                resolve(img);
            };
            img.onerror = img.onabort = reject;
            img.src = canvas.toDataURL();
        };

        if (format === "zip") {
            this.__cached_downloadTileStart = this.downloadTileStart;
            this.downloadTileStart = function(context) {
                const abort = context.finish.bind(context, null);
                if (!context.loadWithAjax) {
                    abort("DeepZoomExt protocol with ZIP does not support fetching data without ajax!");
                }

                var dataStore = context.userData;
                const _this = this;
                dataStore.request = OpenSeadragon.makeAjaxRequest({
                    url: context.src,
                    withCredentials: context.ajaxWithCredentials,
                    headers: context.ajaxHeaders,
                    responseType: "arraybuffer",
                    postData: context.postData,
                    success: async function(request) {
                        var blb;
                        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) {
                            return abort("Empty image response.");
                        }

                        const {zip, entries} = await unzipit.unzipRaw(blb);
                        Promise.all(
                            Object.entries(entries).map(([name, entry]) => {
                                return new Promise((resolve, reject) => {
                                    entry.blob().then(blob => {
                                        if (blob.size > 0) {
                                            const img = new Image();
                                            const objUrl = URL.createObjectURL(blob);
                                            img.onload = () => {
                                                resolve(img);
                                                URL.revokeObjectURL(objUrl);
                                            };
                                            img.onerror = img.onabort = reject;
                                            img.src = objUrl;
                                        } else blackImage(_this, resolve, reject);
                                    });
                                });
                            })
                        ).then(result =>
                            //we return array of promise responses - images
                            context.finish(result, dataStore.request, undefined)
                        ).catch(
                            abort
                        );
                    },
                    error(request) {
                        abort("Image load aborted - XHR error");
                    }
                });
            }
            //no need to provide downloadTileAbort since we keep the meta structure
            this.__cached_downloadTileAbort = this.downloadTileAbort;
            this.downloadTileAbort = OpenSeadragon.TileSource.prototype.downloadTileAbort;
        } else if (this.__cached_downloadTileStart) {
            this.downloadTileStart = this.__cached_downloadTileStart;
            this.downloadTileAbort = this.__cached_downloadTileAbort;
        }
    }

    getTileHashKey(level, x, y, url, ajaxHeaders, postData) {
        return `${x}_${y}/${level}/${this.postData}`;
    }

    getTileCacheDataAsContext2D(cacheObject) {
        //hotfix: in case the cacheObject._data object arrives as array, fix it (webgl drawing did not get called)
        //todo will be replaced by the cache overhaul in OpenSeadragon
        if (!cacheObject._renderedContext) {
            if (Array.isArray(cacheObject._data)) {
                cacheObject._data = cacheObject._data[0];
            } else if (Array.isArray(cacheObject.data)) {
                cacheObject.data = cacheObject.data[0];
            }
        }
        return super.getTileCacheDataAsContext2D(cacheObject);
    }

    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    tileExists( level, x, y ) {
        let rects = this._levelRects[ level ],
            rect,
            scale,
            xMin,
            yMin,
            xMax,
            yMax,
            i;

        if ((this.minLevel && level < this.minLevel) || (this.maxLevel && level > this.maxLevel)) {
            return false;
        }

        if ( !rects || !rects.length ) {
            return true;
        }

        for ( i = rects.length - 1; i >= 0; i-- ) {
            rect = rects[ i ];

            if ( level < rect.minLevel || level > rect.maxLevel ) {
                continue;
            }

            scale = this.getLevelScale( level );
            xMin = rect.x * scale;
            yMin = rect.y * scale;
            xMax = xMin + rect.width * scale;
            yMax = yMin + rect.height * scale;

            xMin = Math.floor( xMin / this._tileWidth );
            yMin = Math.floor( yMin / this._tileWidth ); // DZI tiles are square, so we just use _tileWidth
            xMax = Math.ceil( xMax / this._tileWidth );
            yMax = Math.ceil( yMax / this._tileWidth );

            if ( xMin <= x && x < xMax && yMin <= y && y < yMax ) {
                return true;
            }
        }

        return false;
    }
}


/**
 * @private
 * @inner
 * @function
 */
function configureFromXML( tileSource, xmlDoc ){

    if ( !xmlDoc || !xmlDoc.documentElement ) {
        throw new Error( $.getString( "Errors.Xml" ) );
    }

    var imagesArray    = xmlDoc.documentElement,
        root           = null,
        rootName       = imagesArray.localName || imagesArray.tagName,
        ns             = xmlDoc.documentElement.namespaceURI,
        configuration  = {ImageArray: []},
        displayRects   = [],
        dispRectNodes,
        dispRectNode,
        rectNode,
        sizeNode,
        i;

    if (imagesArray.childNodes.length < 1) throw new Error( "No images defined. There are zero images to display." );

    if ( rootName == "ImageArray" ) {

        try {
            let selectedNode = 0,
                maxWidth = Infinity,
                maxHeight = Infinity;

            for (let child = 0; child < imagesArray.childNodes.length; child++) {
                root = imagesArray.childNodes[child];

                sizeNode = root.getElementsByTagName("Size" )[ 0 ];
                if (sizeNode === undefined) {
                    sizeNode = root.getElementsByTagNameNS(ns, "Size" )[ 0 ];
                }

                let width = parseInt( sizeNode.getAttribute( "Width" ), 10 );
                let height = parseInt( sizeNode.getAttribute( "Height" ), 10 );

                if ( !$.imageFormatSupported( root.getAttribute( "Format" ) ) ) {
                    // noinspection ExceptionCaughtLocallyJS
                    throw new Error(
                        $.getString( "Errors.ImageFormat", root.getAttribute( "Format" ).toUpperCase() )
                    );
                }

                configuration.ImageArray.push({
                    xmlns:       "http://rationai.fi.muni.cz/deepzoom/images",
                    Url:         root.getAttribute( "Url" ),
                    Format:      root.getAttribute( "Format" ),
                    DisplayRect: null,
                    Overlap:     parseInt( root.getAttribute( "Overlap" ), 10 ),
                    TileSize:    parseInt( root.getAttribute( "TileSize" ), 10 ),
                    Size: {
                        Height: height,
                        Width:  width
                    }
                });
            }

            root = imagesArray.childNodes[selectedNode];

            dispRectNodes = root.getElementsByTagName("DisplayRect");
            if (dispRectNodes === undefined) {
                dispRectNodes = root.getElementsByTagNameNS(ns, "DisplayRect")[ 0 ];
            }

            for ( i = 0; i < dispRectNodes.length; i++ ) {
                dispRectNode = dispRectNodes[ i ];
                rectNode     = dispRectNode.getElementsByTagName("Rect")[ 0 ];
                if (rectNode === undefined) {
                    rectNode = dispRectNode.getElementsByTagNameNS(ns,  "Rect")[ 0 ];
                }

                displayRects.push({
                    Rect: {
                        X: parseInt( rectNode.getAttribute( "X" ), 10 ),
                        Y: parseInt( rectNode.getAttribute( "Y" ), 10 ),
                        Width: parseInt( rectNode.getAttribute( "Width" ), 10 ),
                        Height: parseInt( rectNode.getAttribute( "Height" ), 10 ),
                        MinLevel: parseInt( dispRectNode.getAttribute( "MinLevel" ), 10 ),
                        MaxLevel: parseInt( dispRectNode.getAttribute( "MaxLevel" ), 10 )
                    }
                });
            }

            if( displayRects.length ){
                configuration.DisplayRect = displayRects;
            }

            return configureFromObject( tileSource, configuration );

        } catch ( e ) {
            throw (e instanceof Error) ?
                e :
                new Error( $.getString("Errors.Dzi") );
        }
    } else if ( rootName == "Collection" ) {
        throw new Error( $.getString( "Errors.Dzc" ) );
    } else if ( rootName == "Error" ) {
        root = imagesArray.childNodes[0];
        let messageNode = root.getElementsByTagName("Message")[0];
        let message = messageNode.firstChild.nodeValue;
        throw new Error(message);
    }

    throw new Error( $.getString( "Errors.Dzi" ) );
}

/**
 * @private
 * @inner
 * @function
 */
function configureFromObject( tileSource, configuration ){
    var firstImage    = configuration.ImageArray[0],
        fileFormat    = firstImage.Format,
        dispRectData  = configuration.DisplayRect || [],
        width         = Infinity,
        height        = Infinity,
        tileSize      = undefined,
        tileOverlap   = undefined,
        displayRects  = [],
        rectData,
        i;

    for (let i = 0; i < configuration.ImageArray.length; i++) {
        let image = configuration.ImageArray[i],
            imageWidth = parseInt( image.Size.Width, 10 ),
            imageHeight = parseInt( image.Size.Height, 10 ),
            imageTileSize = parseInt( image.TileSize, 10 ),
            imageTileOverlap = parseInt( image.Overlap, 10 );

        if (imageWidth < 1 || imageHeight < 1) {
            image.error = "Missing image data.";
            continue;
        }

        if (tileSize === undefined) {
            tileSize = imageTileSize;
        }

        if (tileOverlap === undefined) {
            tileOverlap = imageTileOverlap;
        }

        if (imageTileSize !== tileSize || imageTileOverlap !== tileOverlap) {
            image.error = "Incompatible layer: the rendering might contain artifacts.";
        }

        if (imageWidth < width || imageHeight < height) {
            //possibly experiment with taking maximum
            width = imageWidth;
            height = imageHeight;
        }
    }

    for ( i = 0; i < dispRectData.length; i++ ) {
        rectData = dispRectData[ i ].Rect;

        displayRects.push( new $.DisplayRect(
            parseInt( rectData.X, 10 ),
            parseInt( rectData.Y, 10 ),
            parseInt( rectData.Width, 10 ),
            parseInt( rectData.Height, 10 ),
            parseInt( rectData.MinLevel, 10 ),
            parseInt( rectData.MaxLevel, 10 )
        ));
    }

    return $.extend(true, {
        width: width, /* width *required */
        height: height, /* height *required */
        tileSize: tileSize, /* tileSize *required */
        tileOverlap: tileOverlap, /* tileOverlap *required */
        minLevel: null, /* minLevel */
        maxLevel: null, /* maxLevel */
        fileFormat: fileFormat, /* fileFormat */
        displayRects: displayRects /* displayRects */
    }, configuration );
}
})(OpenSeadragon);