diff --git a/MultimediaViewer.php b/MultimediaViewer.php index 99277f878..4b973ae52 100644 --- a/MultimediaViewer.php +++ b/MultimediaViewer.php @@ -144,6 +144,16 @@ call_user_func( function() { ), ), $moduleInfo( 'mmv/model' ) ); + $wgResourceModules['mmv.model.Thumbnail'] = array_merge( array( + 'scripts' => array( + 'mmv.model.Thumbnail.js', + ), + + 'dependencies' => array( + 'mmv.model', + ), + ), $moduleInfo( 'mmv/model' ) ); + $wgResourceModules['mmv.provider'] = array_merge( array( 'scripts' => array( 'mmv.provider.Api.js', @@ -247,6 +257,7 @@ call_user_func( function() { 'mmv.model.FileUsage', 'mmv.model.Image', 'mmv.model.Repo', + 'mmv.model.Thumbnail', 'mmv.provider', 'mediawiki.language', 'mmv.multilightbox', diff --git a/resources/mmv/mmv.js b/resources/mmv/mmv.js index 7e1017b9f..ecf47322e 100755 --- a/resources/mmv/mmv.js +++ b/resources/mmv/mmv.js @@ -273,28 +273,50 @@ * Gets the API arguments for various calls to the API to find sized thumbnails. * @param {mw.LightboxInterface} ui * @returns {Object} - * @returns {number} return.requested The width that should be requested from the API - * @returns {number} return.target The ideal width we would like to have - should be the width of the image element later. + * @returns {number} return.real The width that should be requested from the API + * @returns {number} return.css The ideal width we would like to have - should be the width of the image element later. */ MMVP.getImageSizeApiArgs = function ( ui ) { - var requestedWidth, calculatedMaxWidth, - thumb = ui.currentImage.thumbnail, - targetWidth = ui.$imageWrapper.width(), - targetHeight = ui.$imageWrapper.height(); + var thumb = ui.currentImage.thumbnail; - if ( ( targetWidth / targetHeight ) > ( thumb.width / thumb.height ) ) { - // Need to find width corresponding to highest height we can have. - calculatedMaxWidth = ( thumb.width / thumb.height ) * targetHeight; - requestedWidth = this.findNextHighestImageSize( calculatedMaxWidth ); + return this.getThumbnailWidth( ui.$imageWrapper.width(), ui.$imageWrapper.height(), + thumb.width, thumb.height ); + }; + + /** + * Finds the largest width for an image so that it will still fit into a given bounding box, + * based on the size of a sample (some smaller version of the same image, like the thumbnail + * shown in the article) which is used to calculate the ratio. + * + * Returns two values, a CSS width which is the size in pixels that should be used so the image + * fits exactly into the bounding box, and a real width which should be the size of the + * downloaded image in pixels. The two will be different for two reasons: + * - images are bucketed for more efficient caching, so the real width will always be one of + * the numbers in this.imageWidthBuckets + * - for devices with high pixel density (multiple actual pixels per CSS pixel) we want to use a + * larger image so that there will be roughly one image pixel per physical display pixel + * + * @param {number} boundingWidth width of the bounding box + * @param {number} boundingHeight height of the bounding box + * @param {number} sampleWidth width of the sample image + * @param {number} sampleHeight height of the sample image + * @return {{css: number, real: number}} 'css' field will contain the width of the + * thumbnail in CSS pixels, 'real' the actual image size that should be requested. + */ + MMVP.getThumbnailWidth = function( boundingWidth, boundingHeight, sampleWidth, sampleHeight ) { + var cssWidth, bucketedWidth; + if ( ( boundingWidth / boundingHeight ) > ( sampleWidth / sampleHeight ) ) { + // we are limited by height; we need to calculate the max width that fits + cssWidth = ( sampleWidth / sampleHeight ) * boundingHeight; } else { - // Simple case, ratio tells us we're limited by width - requestedWidth = this.findNextHighestImageSize( targetWidth ); + // simple case, ratio tells us we're limited by width + cssWidth = boundingWidth; } + bucketedWidth = this.findNextHighestImageSize( cssWidth ); return { - // Factor in pixel ratio so we get as many pixels as the device supports, see b/60388 - requested: requestedWidth * $.devicePixelRatio(), - target: calculatedMaxWidth || targetWidth + css: cssWidth, + real: bucketedWidth * $.devicePixelRatio() }; }; @@ -339,11 +361,13 @@ */ MMVP.resize = function ( ui ) { var viewer = this, - fileTitle = this.currentImageFileTitle; + fileTitle = this.currentImageFileTitle, + imageWidths; if ( fileTitle ) { - this.fetchImageInfo( fileTitle ).done( function ( imageData, repoInfo, targetWidth, requestedWidth ) { - viewer.loadResizedImage( ui, imageData, targetWidth, requestedWidth ); + imageWidths = this.getImageSizeApiArgs( ui ); + this.fetchImageInfoWithThumbnail( fileTitle, imageWidths.real ).then( function( imageInfo ) { + viewer.loadResizedImage( ui, imageInfo, imageWidths.css, imageWidths.real ); } ); } @@ -715,17 +739,15 @@ */ MMVP.fetchImageInfo = function ( fileTitle ) { var widths = this.getImageSizeApiArgs( this.ui ), - targetWidth = widths.target, - requestedWidth = widths.requested; + targetWidth = widths.css, + requestedWidth = widths.real; return $.when( this.fileRepoInfoProvider.get(), this.imageInfoProvider.get( fileTitle ), this.thumbnailInfoProvider.get( fileTitle, requestedWidth ) - ).then( function( fileRepoInfoHash, imageInfo, thumbnailData ) { - var thumbnailUrl = thumbnailData[0], - thumbnailWidth = thumbnailData[1]; - imageInfo.addThumbUrl( thumbnailWidth, thumbnailUrl ); + ).then( function( fileRepoInfoHash, imageInfo, thumbnail ) { + imageInfo.addThumbUrl( thumbnail.width, thumbnail.url ); return $.Deferred().resolve( imageInfo, fileRepoInfoHash, targetWidth, requestedWidth ); } ); }; diff --git a/resources/mmv/model/mmv.model.Thumbnail.js b/resources/mmv/model/mmv.model.Thumbnail.js new file mode 100644 index 000000000..b1c8b49e6 --- /dev/null +++ b/resources/mmv/model/mmv.model.Thumbnail.js @@ -0,0 +1,47 @@ +/* + * This file is part of the MediaWiki extension MultimediaViewer. + * + * MultimediaViewer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * MultimediaViewer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MultimediaViewer. If not, see . + */ + +( function ( mw ) { + /** + * @class mw.mmv.model.Thumbnail + * Represents information about an image thumbnail + * @constructor + * @param {string} url URL to the thumbnail + * @param {number} width Width in pixels + * @param {number} height Height in pixels + */ + function Thumbnail( + url, + width, + height + ) { + if ( !url || !width || !height ) { + throw 'All parameters are required and cannot be empty or zero'; + } + + /** @property {string} url The URL to the thumbnail */ + this.url = url; + + /** @property {number} width The width of the thumbnail in pixels */ + this.width = width; + + /** @property {number} height The height of the thumbnail in pixels */ + this.height = height; + } + + mw.mmv.model.Thumbnail = Thumbnail; +}( mediaWiki ) ); diff --git a/resources/mmv/provider/mmv.provider.ThumbnailInfo.js b/resources/mmv/provider/mmv.provider.ThumbnailInfo.js index 60afbfdeb..6ba5278cd 100644 --- a/resources/mmv/provider/mmv.provider.ThumbnailInfo.js +++ b/resources/mmv/provider/mmv.provider.ThumbnailInfo.js @@ -32,16 +32,19 @@ /** * @method - * Runs an API GET request to get the thumbnail info. + * Runs an API GET request to get the thumbnail info for the specified size. + * The thumbnail always has the same aspect ratio as the full image. + * One of width or height can be null; if both are set, the API will return the largest + * thumbnail which fits into a width x height bounding box (or the full-sized image - whichever + * is smaller). * @param {mw.Title} file - * @param {number} width thumbnail width - * @return {jQuery.Promise.} a promise which resolves to the thumbnail URL and - * the actual width of the thumbnail (which might be smaller than the requested width, - * in case the size we requested was larger than the full image size). + * @param {number} width thumbnail width in pixels + * @param {number} height thumbnail height in pixels + * @return {jQuery.Promise.} */ - ThumbnailInfo.prototype.get = function( file, width ) { + ThumbnailInfo.prototype.get = function( file, width, height ) { var provider = this, - cacheKey = file.getPrefixedDb() + '|' + width; + cacheKey = file.getPrefixedDb() + '|' + ( width || '' ) + '|' + ( height || '' ); if ( !this.cache[cacheKey] ) { this.cache[cacheKey] = this.api.get( { @@ -49,13 +52,25 @@ prop: 'imageinfo', titles: file.getPrefixedDb(), iiprop: 'url', - iiurlwidth: width, + iiurlwidth: width, // mw.Api will omit null/undefined parameters + iiurlheight: height, format: 'json' } ).then( function( data ) { return provider.getQueryPage( file, data ); } ).then( function( page ) { if ( page.imageinfo && page.imageinfo[0] ) { - return $.Deferred().resolve( page.imageinfo[0].thumburl, page.imageinfo[0].thumbwidth ); + var imageInfo = page.imageinfo[0]; + if ( imageInfo.thumburl && imageInfo.thumbwidth && imageInfo.thumbheight ) { + return $.Deferred().resolve( + new mw.mmv.model.Thumbnail( + imageInfo.thumburl, + imageInfo.thumbwidth, + imageInfo.thumbheight + ) + ); + } else { + return $.Deferred().reject( 'error in provider, thumb info not found' ); + } } else if ( page.missing === '' && page.imagerepository === '' ) { return $.Deferred().reject( 'file does not exist: ' + file.getPrefixedDb() ); } else { diff --git a/tests/qunit/mmv.model.test.js b/tests/qunit/mmv.model.test.js index aac1854d5..7379b4154 100644 --- a/tests/qunit/mmv.model.test.js +++ b/tests/qunit/mmv.model.test.js @@ -112,4 +112,21 @@ assert.strictEqual( dbRepo.getArticlePath(), 'http://example.org/wiki/$1', 'DB article path is set correctly' ); assert.strictEqual( apiRepo.getArticlePath(), 'http://example.net/wiki/$1', 'API article path is set correctly' ); } ); + + QUnit.test( 'Thumbnail constructor sanity check', 4, function ( assert ) { + var width = 23, + height = 42, + url = 'http://example.com/foo.jpg', + thumbnail = new mw.mmv.model.Thumbnail( url, width, height ); + + assert.strictEqual( thumbnail.url, url, 'Url is set correctly' ); + assert.strictEqual( thumbnail.width, width, 'Width is set correctly' ); + assert.strictEqual( thumbnail.height, height, 'Height is set correctly' ); + + try { + thumbnail = new mw.mmv.model.Thumbnail( url, width ); + } catch (e) { + assert.ok( e, 'Exception is thrown when parameters are missing'); + } + } ); }( mediaWiki ) ); diff --git a/tests/qunit/mmv.test.js b/tests/qunit/mmv.test.js index 2514ae988..2d08b8aca 100644 --- a/tests/qunit/mmv.test.js +++ b/tests/qunit/mmv.test.js @@ -514,8 +514,8 @@ widths = viewer.getImageSizeApiArgs( ui ); - assert.strictEqual( widths.target, 150/100*200, 'Correct target width was computed.' ); - assert.strictEqual( widths.requested, 320 * $.devicePixelRatio(), 'Correct requested width was computed.' ); + assert.strictEqual( widths.css, 150/100*200, 'Correct CSS width was computed.' ); + assert.strictEqual( widths.real, 320 * $.devicePixelRatio(), 'Correct real width was computed.' ); // Fake viewport dimensions, width/height == 1.0, we are limited by width ui.$imageWrapper.height( 600 ); @@ -523,8 +523,8 @@ widths = viewer.getImageSizeApiArgs( ui ); - assert.strictEqual( widths.target, 600, 'Correct target width was computed.' ); - assert.strictEqual( widths.requested, 640 * $.devicePixelRatio(), 'Correct requested width was computed.' ); + assert.strictEqual( widths.css, 600, 'Correct CSS width was computed.' ); + assert.strictEqual( widths.real, 640 * $.devicePixelRatio(), 'Correct real width was computed.' ); ui.unattach(); diff --git a/tests/qunit/provider/mmv.provider.ThumbnailInfo.test.js b/tests/qunit/provider/mmv.provider.ThumbnailInfo.test.js index 27b4145c3..1961ae276 100644 --- a/tests/qunit/provider/mmv.provider.ThumbnailInfo.test.js +++ b/tests/qunit/provider/mmv.provider.ThumbnailInfo.test.js @@ -25,7 +25,7 @@ assert.ok( thumbnailInfoProvider ); } ); - QUnit.asyncTest( 'ThumbnailInfo get test', 5, function ( assert ) { + QUnit.asyncTest( 'ThumbnailInfo get test', 7, function ( assert ) { var apiCallCount = 0, api = { get: function() { apiCallCount++; @@ -54,11 +54,12 @@ file = new mw.Title( 'File:Stuff.jpg' ), thumbnailInfoProvider = new mw.mmv.provider.ThumbnailInfo( api ); - thumbnailInfoProvider.get( file, 100 ).then( function( thumnailUrl, thumbnailWidth ) { - assert.strictEqual( thumnailUrl, + thumbnailInfoProvider.get( file, 100 ).then( function( thumbnail ) { + assert.strictEqual( thumbnail.url, 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Stuff.jpg/51px-Stuff.jpg', 'URL is set correctly' ); - assert.strictEqual( thumbnailWidth, 95, 'actual width is set correctly' ); + assert.strictEqual( thumbnail.width, 95, 'actual width is set correctly' ); + assert.strictEqual( thumbnail.height, 200, 'actual height is set correctly' ); } ).then( function() { assert.strictEqual( apiCallCount, 1 ); // call the data provider a second time to check caching @@ -69,6 +70,10 @@ return thumbnailInfoProvider.get( file, 110 ); } ).then( function() { assert.strictEqual( apiCallCount, 2 ); + // call it again, with a height specified, to check caching + return thumbnailInfoProvider.get( file, 110, 100 ); + } ).then( function() { + assert.strictEqual( apiCallCount, 3 ); QUnit.start(); } ); } ); @@ -130,4 +135,28 @@ QUnit.start(); } ); } ); + + QUnit.asyncTest( 'ThumbnailInfo fail test 3', 1, function ( assert ) { + var api = { get: function() { + return $.Deferred().resolve( { + query: { + pages: { + '-1': { + title: 'File:Stuff.jpg', + imageinfo: [ + {} + ] + } + } + } + } ); + } }, + file = new mw.Title( 'File:Stuff.jpg' ), + thumbnailInfoProvider = new mw.mmv.provider.ThumbnailInfo( api ); + + thumbnailInfoProvider.get( file, 100 ).fail( function() { + assert.ok( true, 'promise rejected when thumbnail info is missing' ); + QUnit.start(); + } ); + } ); }( mediaWiki, jQuery ) );