diff --git a/RELEASE-NOTES-1.21 b/RELEASE-NOTES-1.21 index a1fa4caf428..7931834d501 100644 --- a/RELEASE-NOTES-1.21 +++ b/RELEASE-NOTES-1.21 @@ -19,6 +19,7 @@ production. * (bug 34876) jquery.makeCollapsible has been improved in performance. * Added ContentHandler facility to allow extensions to support other content than wikitext. See docs/contenthandler.txt for details. +* $wgResponsiveImages is added to support images on high-DPI mobile and desktop displays. === Bug fixes in 1.21 === * (bug 40353) SpecialDoubleRedirect should support interwiki redirects. diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 2e1e82f517f..ae8ff58fa20 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1088,6 +1088,16 @@ $wgThumbUpright = 0.75; */ $wgDirectoryMode = 0777; +/** + * Generate and use thumbnails suitable for screens with 1.5 and 2.0 pixel densities. + * + * This means a 320x240 use of an image on the wiki will also generate 480x360 and 640x480 + * thumbnails, output via data-src-1-5 and data-src-2-0. Runtime JavaScript switches the + * images in after loading the original low-resolution versions depending on the reported + * window.devicePixelRatio. + */ +$wgResponsiveImages = true; + /** * @name DJVU settings * @{ diff --git a/includes/Html.php b/includes/Html.php index 8cb99f55cd9..a07dd4c2d38 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -942,4 +942,22 @@ class Html { return $s; } + + /** + * Generate a srcset attribute value from an array mapping pixel densities + * to URLs. Note that srcset supports width and height values as well, which + * are not used here. + * + * @param array $urls + * @return string + */ + static function srcSet( $urls ) { + $candidates = array(); + foreach( $urls as $density => $url ) { + // Image candidate syntax per current whatwg live spec, 2012-09-23: + // http://www.whatwg.org/specs/web-apps/current-work/multipage/embedded-content-1.html#attr-img-srcset + $candidates[] = "{$url} {$density}x"; + } + return implode( ", ", $candidates ); + } } diff --git a/includes/Linker.php b/includes/Linker.php index c17e2d1687f..0f4516575dd 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -676,6 +676,7 @@ class Linker { if ( !$thumb ) { $s = self::makeBrokenImageLinkObj( $title, $fp['title'], '', '', '', $time == true ); } else { + self::processResponsiveImages( $file, $thumb, $hp ); $params = array( 'alt' => $fp['alt'], 'title' => $fp['title'], @@ -796,6 +797,7 @@ class Linker { $hp['width'] = isset( $fp['upright'] ) ? 130 : 180; } $thumb = false; + $noscale = false; if ( !$exists ) { $outerWidth = $hp['width'] + 2; @@ -814,6 +816,7 @@ class Linker { } elseif ( isset( $fp['framed'] ) ) { // Use image dimensions, don't scale $thumb = $file->getUnscaledThumb( $hp ); + $noscale = true; } else { # Do not present an image bigger than the source, for bitmap-style images # This is a hack to maintain compatibility with arbitrary pre-1.10 behaviour @@ -847,6 +850,9 @@ class Linker { $s .= wfMessage( 'thumbnail_error', '' )->escaped(); $zoomIcon = ''; } else { + if ( !$noscale ) { + self::processResponsiveImages( $file, $thumb, $hp ); + } $params = array( 'alt' => $fp['alt'], 'title' => $fp['title'], @@ -873,6 +879,37 @@ class Linker { return str_replace( "\n", ' ', $s ); } + /** + * Process responsive images: add 1.5x and 2x subimages to the thumbnail, where + * applicable. + * + * @param File $file + * @param MediaOutput $thumb + * @param array $hp image parameters + */ + protected static function processResponsiveImages( $file, $thumb, $hp ) { + global $wgResponsiveImages; + if ( $wgResponsiveImages ) { + $hp15 = $hp; + $hp15['width'] = round( $hp['width'] * 1.5 ); + $hp20 = $hp; + $hp20['width'] = $hp['width'] * 2; + if ( isset( $hp['height'] ) ) { + $hp15['height'] = round( $hp['height'] * 1.5 ); + $hp20['height'] = $hp['height'] * 2; + } + + $thumb15 = $file->transform( $hp15 ); + $thumb20 = $file->transform( $hp20 ); + if ( $thumb15->url !== $thumb->url ) { + $thumb->responsiveUrls['1.5'] = $thumb15->url; + } + if ( $thumb20->url !== $thumb->url ) { + $thumb->responsiveUrls['2'] = $thumb20->url; + } + } + } + /** * Make a "broken" link to an image * diff --git a/includes/OutputPage.php b/includes/OutputPage.php index dd9c9e3f821..3578568651d 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -2462,7 +2462,7 @@ $templates */ private function addDefaultModules() { global $wgIncludeLegacyJavaScript, $wgPreloadJavaScriptMwUtil, $wgUseAjax, - $wgAjaxWatch; + $wgAjaxWatch, $wgResponsiveImages; // Add base resources $this->addModules( array( @@ -2503,6 +2503,11 @@ $templates if ( $this->isArticle() && $this->getUser()->getOption( 'editondblclick' ) ) { $this->addModules( 'mediawiki.action.view.dblClickEdit' ); } + + // Support for high-density display images + if ( $wgResponsiveImages ) { + $this->addModules( 'mediawiki.hidpi' ); + } } /** diff --git a/includes/media/MediaTransformOutput.php b/includes/media/MediaTransformOutput.php index c5e4566be61..69bdc2fb212 100644 --- a/includes/media/MediaTransformOutput.php +++ b/includes/media/MediaTransformOutput.php @@ -33,6 +33,13 @@ abstract class MediaTransformOutput { var $file; var $width, $height, $url, $page, $path; + + /** + * @var array Associative array mapping optional supplementary image files + * from pixel density (eg 1.5 or 2) to additional URLs. + */ + public $responsiveUrls = array(); + protected $storagePath = false; /** @@ -324,7 +331,7 @@ class ThumbnailImage extends MediaTransformOutput { 'alt' => $alt, 'src' => $this->url, 'width' => $this->width, - 'height' => $this->height, + 'height' => $this->height ); if ( !empty( $options['valign'] ) ) { $attribs['style'] = "vertical-align: {$options['valign']}"; @@ -332,6 +339,11 @@ class ThumbnailImage extends MediaTransformOutput { if ( !empty( $options['img-class'] ) ) { $attribs['class'] = $options['img-class']; } + + // Additional densities for responsive images, if specified. + if ( !empty( $this->responsiveUrls ) ) { + $attribs['srcset'] = Html::srcSet( $this->responsiveUrls ); + } return $this->linkWrap( $linkAttribs, Xml::element( 'img', $attribs ) ); } diff --git a/resources/Resources.php b/resources/Resources.php index cccc64574cc..c906143e98f 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -178,6 +178,9 @@ return array( 'jquery.getAttrs' => array( 'scripts' => 'resources/jquery/jquery.getAttrs.js', ), + 'jquery.hidpi' => array( + 'scripts' => 'resources/jquery/jquery.hidpi.js', + ), 'jquery.highlightText' => array( 'scripts' => 'resources/jquery/jquery.highlightText.js', 'dependencies' => 'jquery.mwExtension', @@ -621,6 +624,12 @@ return array( 'feedback-bugnew', ), ), + 'mediawiki.hidpi' => array( + 'scripts' => 'resources/mediawiki/mediawiki.hidpi.js', + 'dependencies' => array( + 'jquery.hidpi', + ), + ), 'mediawiki.htmlform' => array( 'scripts' => 'resources/mediawiki/mediawiki.htmlform.js', ), diff --git a/resources/jquery/jquery.hidpi.js b/resources/jquery/jquery.hidpi.js new file mode 100644 index 00000000000..b7335ffe864 --- /dev/null +++ b/resources/jquery/jquery.hidpi.js @@ -0,0 +1,119 @@ +/** + * Responsive images based on 'srcset' and 'window.devicePixelRatio' emulation where needed. + * + * Call $().hidpi() on a document or part of a document to replace image srcs in that section. + * + * $.devicePixelRatio() can be used to supplement window.devicePixelRatio with support on + * some additional browsers. + */ +( function ( $ ) { + +/** + * Detect reported or approximate device pixel ratio. + * 1.0 means 1 CSS pixel is 1 hardware pixel + * 2.0 means 1 CSS pixel is 2 hardware pixels + * etc + * + * Uses window.devicePixelRatio if available, or CSS media queries on IE. + * + * @method + * @returns {number} Device pixel ratio + */ +$.devicePixelRatio = function () { + if ( window.devicePixelRatio !== undefined ) { + // Most web browsers: + // * WebKit (Safari, Chrome, Android browser, etc) + // * Opera + // * Firefox 18+ + return window.devicePixelRatio; + } else if ( window.msMatchMedia !== undefined ) { + // Windows 8 desktops / tablets, probably Windows Phone 8 + // + // IE 10 doesn't report pixel ratio directly, but we can get the + // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for + // simplicity, but you may get different values depending on zoom + // factor, size of screen and orientation in Metro IE. + if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) { + return 2; + } else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) { + return 1.5; + } else { + return 1; + } + } else { + // Legacy browsers... + // Assume 1 if unknown. + return 1; + } +}; + +/** + * Implement responsive images based on srcset attributes, if browser has no + * native srcset support. + * + * @method + * @returns {jQuery} This selection + */ +$.fn.hidpi = function () { + var $target = this, + // @todo add support for dpi media query checks on Firefox, IE + devicePixelRatio = $.devicePixelRatio(), + testImage = new Image(); + + if ( devicePixelRatio > 1 && testImage.srcset === undefined ) { + // No native srcset support. + $target.find( 'img' ).each( function () { + var $img = $( this ), + srcset = $img.attr( 'srcset' ), + match; + if ( typeof srcset === 'string' && srcset !== '' ) { + match = $.matchSrcSet( devicePixelRatio, srcset ); + if (match !== null ) { + $img.attr( 'src', match ); + } + } + }); + } + + return $target; +}; + +/** + * Match a srcset entry for the given device pixel ratio + * + * @param {number} devicePixelRatio + * @param {string} srcset + * @return {mixed} null or the matching src string + * + * Exposed for testing. + */ +$.matchSrcSet = function ( devicePixelRatio, srcset ) { + var candidates, + candidate, + bits, + src, + i, + ratioStr, + ratio, + selectedRatio = 1, + selectedSrc = null; + candidates = srcset.split( / *, */ ); + for ( i = 0; i < candidates.length; i++ ) { + candidate = candidates[i]; + bits = candidate.split( / +/ ); + src = bits[0]; + if ( bits.length > 1 && bits[1].charAt( bits[1].length - 1 ) === 'x' ) { + ratioStr = bits[1].substr( 0, bits[1].length - 1 ); + ratio = parseFloat( ratioStr ); + if ( ratio > devicePixelRatio ) { + // Too big, skip! + } else if ( ratio > selectedRatio ) { + selectedRatio = ratio; + selectedSrc = src; + } + } + } + return selectedSrc; +}; + +}( jQuery ) ); diff --git a/resources/mediawiki/mediawiki.hidpi.js b/resources/mediawiki/mediawiki.hidpi.js new file mode 100644 index 00000000000..19795730479 --- /dev/null +++ b/resources/mediawiki/mediawiki.hidpi.js @@ -0,0 +1,5 @@ +$( function() { + // Apply hidpi images on DOM-ready + // Some may have already partly preloaded at low resolution. + $( 'body' ).hidpi(); +} ); \ No newline at end of file diff --git a/tests/parser/parserTest.inc b/tests/parser/parserTest.inc index fb0c84825ab..cd2289d33b6 100644 --- a/tests/parser/parserTest.inc +++ b/tests/parser/parserTest.inc @@ -671,7 +671,7 @@ class ParserTest { 'wgNoFollowLinks' => true, 'wgNoFollowDomainExceptions' => array(), 'wgThumbnailScriptPath' => false, - 'wgUseImageResize' => false, + 'wgUseImageResize' => true, 'wgLocaltimezone' => 'UTC', 'wgAllowExternalImages' => true, 'wgUseTidy' => false, diff --git a/tests/parser/parserTests.txt b/tests/parser/parserTests.txt index 61cf508a57a..007ae924e4e 100644 --- a/tests/parser/parserTests.txt +++ b/tests/parser/parserTests.txt @@ -5457,7 +5457,7 @@ Thumbnail image with link parameter !! input [[Image:foobar.jpg|thumb|link=http://example.com/|Title]] !! result -
+ !! end @@ -5531,7 +5531,7 @@ Thumbnail image caption with a free URL !! input [[Image:foobar.jpg|thumb|http://example.com]] !! result - + !! end @@ -5540,7 +5540,7 @@ Thumbnail image caption with a free URL and explicit alt !! input [[Image:foobar.jpg|thumb|http://example.com|alt=Alteration]] !! result - + !! end @@ -5549,7 +5549,7 @@ BUG 1887: A ISBN with a thumbnail !! input [[Image:foobar.jpg|thumb|ISBN 1235467890]] !! result - + !! end @@ -5558,7 +5558,7 @@ BUG 1887: A RFC with a thumbnail !! input [[Image:foobar.jpg|thumb|This is RFC 12354]] !! result -Blabla|blabla.
@@ -8855,14 +8855,14 @@ File:foobar.jpg|{{Test|unamedParam|alt=param}}|alt=galleryalt !! resultcaption
@@ -11078,7 +11078,7 @@ File:foobar.jpg|caption|alt=galleryalt|link=http://www.example.org !! resultcaption
@@ -11097,7 +11097,7 @@ File:foobar.jpg|caption|alt=galleryalt|link=" onclick="alert('malicious javascri !! resultcaption
diff --git a/tests/phpunit/includes/parser/NewParserTest.php b/tests/phpunit/includes/parser/NewParserTest.php index 848991ea5bc..8ddfa65e0e0 100644 --- a/tests/phpunit/includes/parser/NewParserTest.php +++ b/tests/phpunit/includes/parser/NewParserTest.php @@ -303,7 +303,7 @@ class NewParserTest extends MediaWikiTestCase { 'wgNoFollowLinks' => true, 'wgNoFollowDomainExceptions' => array(), 'wgThumbnailScriptPath' => false, - 'wgUseImageResize' => false, + 'wgUseImageResize' => true, 'wgUseTeX' => isset( $opts['math'] ), 'wgMathDirectory' => $uploadDir . '/math', 'wgLocaltimezone' => 'UTC', diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index be5f4ac01e8..01072d83c4f 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -13,6 +13,7 @@ return array( 'tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js', 'tests/qunit/suites/resources/jquery/jquery.delayedBind.test.js', 'tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js', + 'tests/qunit/suites/resources/jquery/jquery.hidpi.test.js', 'tests/qunit/suites/resources/jquery/jquery.highlightText.test.js', 'tests/qunit/suites/resources/jquery/jquery.localize.test.js', 'tests/qunit/suites/resources/jquery/jquery.mwExtension.test.js', @@ -41,6 +42,7 @@ return array( 'jquery.colorUtil', 'jquery.delayedBind', 'jquery.getAttrs', + 'jquery.hidpi', 'jquery.highlightText', 'jquery.localize', 'jquery.mwExtension', diff --git a/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js b/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js new file mode 100644 index 00000000000..cf309df8006 --- /dev/null +++ b/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js @@ -0,0 +1,20 @@ +QUnit.module( 'jquery.hidpi', QUnit.newMwEnvironment() ); + +QUnit.test( 'devicePixelRatio', function ( assert ) { + var devicePixelRatio = $.devicePixelRatio(); + assert.equal( typeof devicePixelRatio, 'number', '$.devicePixelRatio() returns a number' ); +}); + +QUnit.test( 'matchSrcSet', function ( assert ) { + var srcset = 'onefive.png 1.5x, two.png 2x'; + + // Nice exact matches + assert.equal( $.matchSrcSet( 1, srcset ), null, '1.0 gives no match' ); + assert.equal( $.matchSrcSet( 1.5, srcset ), 'onefive.png', '1.5 gives match' ); + assert.equal( $.matchSrcSet( 2, srcset ), 'two.png', '2 gives match' ); + + // Non-exact matches; should return the next-biggest specified + assert.equal( $.matchSrcSet( 1.25, srcset ), null, '1.25 gives no match' ); + assert.equal( $.matchSrcSet( 1.75, srcset ), 'onefive.png', '1.75 gives match to 1.5' ); + assert.equal( $.matchSrcSet( 2.25, srcset ), 'two.png', '2.25 gives match to 2' ); +});