JpegHandler.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. /**
  3. * Handler for JPEG images.
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @ingroup Media
  22. */
  23. use MediaWiki\Shell\Shell;
  24. /**
  25. * JPEG specific handler.
  26. * Inherits most stuff from BitmapHandler, just here to do the metadata handler differently.
  27. *
  28. * Metadata stuff common to Jpeg and built-in Tiff (not PagedTiffHandler) is
  29. * in ExifBitmapHandler.
  30. *
  31. * @ingroup Media
  32. */
  33. class JpegHandler extends ExifBitmapHandler {
  34. const SRGB_EXIF_COLOR_SPACE = 'sRGB';
  35. const SRGB_ICC_PROFILE_DESCRIPTION = 'sRGB IEC61966-2.1';
  36. public function normaliseParams( $image, &$params ) {
  37. if ( !parent::normaliseParams( $image, $params ) ) {
  38. return false;
  39. }
  40. if ( isset( $params['quality'] ) && !self::validateQuality( $params['quality'] ) ) {
  41. return false;
  42. }
  43. return true;
  44. }
  45. public function validateParam( $name, $value ) {
  46. if ( $name === 'quality' ) {
  47. return self::validateQuality( $value );
  48. } else {
  49. return parent::validateParam( $name, $value );
  50. }
  51. }
  52. /** Validate and normalize quality value to be between 1 and 100 (inclusive).
  53. * @param int $value Quality value, will be converted to integer or 0 if invalid
  54. * @return bool True if the value is valid
  55. */
  56. private static function validateQuality( $value ) {
  57. return $value === 'low';
  58. }
  59. public function makeParamString( $params ) {
  60. // Prepend quality as "qValue-". This has to match parseParamString() below
  61. $res = parent::makeParamString( $params );
  62. if ( $res && isset( $params['quality'] ) ) {
  63. $res = "q{$params['quality']}-$res";
  64. }
  65. return $res;
  66. }
  67. public function parseParamString( $str ) {
  68. // $str contains "qlow-200px" or "200px" strings because thumb.php would strip the filename
  69. // first - check if the string begins with "qlow-", and if so, treat it as quality.
  70. // Pass the first portion, or the whole string if "qlow-" not found, to the parent
  71. // The parsing must match the makeParamString() above
  72. $res = false;
  73. $m = false;
  74. if ( preg_match( '/q([^-]+)-(.*)$/', $str, $m ) ) {
  75. $v = $m[1];
  76. if ( self::validateQuality( $v ) ) {
  77. $res = parent::parseParamString( $m[2] );
  78. if ( $res ) {
  79. $res['quality'] = $v;
  80. }
  81. }
  82. } else {
  83. $res = parent::parseParamString( $str );
  84. }
  85. return $res;
  86. }
  87. protected function getScriptParams( $params ) {
  88. $res = parent::getScriptParams( $params );
  89. if ( isset( $params['quality'] ) ) {
  90. $res['quality'] = $params['quality'];
  91. }
  92. return $res;
  93. }
  94. public function getMetadata( $image, $filename ) {
  95. try {
  96. $meta = BitmapMetadataHandler::Jpeg( $filename );
  97. if ( !is_array( $meta ) ) {
  98. // This should never happen, but doesn't hurt to be paranoid.
  99. throw new MWException( 'Metadata array is not an array' );
  100. }
  101. $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version();
  102. return serialize( $meta );
  103. } catch ( Exception $e ) {
  104. // BitmapMetadataHandler throws an exception in certain exceptional
  105. // cases like if file does not exist.
  106. wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
  107. /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases
  108. * * No metadata in the file
  109. * * Something is broken in the file.
  110. * However, if the metadata support gets expanded then you can't tell if the 0 is from
  111. * a broken file, or just no props found. A broken file is likely to stay broken, but
  112. * a file which had no props could have props once the metadata support is improved.
  113. * Thus switch to using -1 to denote only a broken file, and use an array with only
  114. * MEDIAWIKI_EXIF_VERSION to denote no props.
  115. */
  116. return ExifBitmapHandler::BROKEN_FILE;
  117. }
  118. }
  119. /**
  120. * @param File $file
  121. * @param array $params Rotate parameters.
  122. * 'rotation' clockwise rotation in degrees, allowed are multiples of 90
  123. * @since 1.21
  124. * @return bool|MediaTransformError
  125. */
  126. public function rotate( $file, $params ) {
  127. global $wgJpegTran;
  128. $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
  129. if ( $wgJpegTran && is_executable( $wgJpegTran ) ) {
  130. $command = Shell::command( $wgJpegTran,
  131. '-rotate',
  132. $rotation,
  133. '-outfile',
  134. $params['dstPath'],
  135. $params['srcPath']
  136. );
  137. $result = $command
  138. ->includeStderr()
  139. ->execute();
  140. if ( $result->getExitCode() !== 0 ) {
  141. $this->logErrorForExternalProcess( $result->getExitCode(),
  142. $result->getStdout(),
  143. $command
  144. );
  145. return new MediaTransformError( 'thumbnail_error', 0, 0, $result->getStdout() );
  146. }
  147. return false;
  148. } else {
  149. return parent::rotate( $file, $params );
  150. }
  151. }
  152. public function supportsBucketing() {
  153. return true;
  154. }
  155. public function sanitizeParamsForBucketing( $params ) {
  156. $params = parent::sanitizeParamsForBucketing( $params );
  157. // Quality needs to be cleared for bucketing. Buckets need to be default quality
  158. if ( isset( $params['quality'] ) ) {
  159. unset( $params['quality'] );
  160. }
  161. return $params;
  162. }
  163. /**
  164. * @inheritDoc
  165. */
  166. protected function transformImageMagick( $image, $params ) {
  167. global $wgUseTinyRGBForJPGThumbnails;
  168. $ret = parent::transformImageMagick( $image, $params );
  169. if ( $ret ) {
  170. return $ret;
  171. }
  172. if ( $wgUseTinyRGBForJPGThumbnails ) {
  173. // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller
  174. // (and free) TinyRGB
  175. /**
  176. * We'll want to replace the color profile for JPGs:
  177. * * in the sRGB color space, or with the sRGB profile
  178. * (other profiles will be left untouched)
  179. * * without color space or profile, in which case browsers
  180. * should assume sRGB, but don't always do (e.g. on wide-gamut
  181. * monitors (unless it's meant for low bandwith)
  182. * @see https://phabricator.wikimedia.org/T134498
  183. */
  184. $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ];
  185. $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ];
  186. // we'll also add TinyRGB profile to images lacking a profile, but
  187. // only if they're not low quality (which are meant to save bandwith
  188. // and we don't want to increase the filesize by adding a profile)
  189. if ( isset( $params['quality'] ) && $params['quality'] > 30 ) {
  190. $profiles[] = '-';
  191. }
  192. $this->swapICCProfile(
  193. $params['dstPath'],
  194. $colorSpaces,
  195. $profiles,
  196. realpath( __DIR__ ) . '/tinyrgb.icc'
  197. );
  198. }
  199. return false;
  200. }
  201. /**
  202. * Swaps an embedded ICC profile for another, if found.
  203. * Depends on exiftool, no-op if not installed.
  204. * @param string $filepath File to be manipulated (will be overwritten)
  205. * @param array $colorSpaces Only process files with this/these Color Space(s)
  206. * @param array $oldProfileStrings Exact name(s) of color profile to look for
  207. * (the one that will be replaced)
  208. * @param string $profileFilepath ICC profile file to apply to the file
  209. * @since 1.26
  210. * @return bool
  211. */
  212. public function swapICCProfile( $filepath, array $colorSpaces,
  213. array $oldProfileStrings, $profileFilepath
  214. ) {
  215. global $wgExiftool;
  216. if ( !$wgExiftool || !is_executable( $wgExiftool ) ) {
  217. return false;
  218. }
  219. $result = Shell::command(
  220. $wgExiftool,
  221. '-EXIF:ColorSpace',
  222. '-ICC_Profile:ProfileDescription',
  223. '-S',
  224. '-T',
  225. $filepath
  226. )
  227. ->includeStderr()
  228. ->execute();
  229. // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc]
  230. $data = explode( "\t", trim( $result->getStdout() ) );
  231. if ( $result->getExitCode() !== 0 ) {
  232. return false;
  233. }
  234. // Make a regex out of the source data to match it to an array of color
  235. // spaces in a case-insensitive way
  236. $colorSpaceRegex = '/' . preg_quote( $data[0], '/' ) . '/i';
  237. if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) {
  238. // We can't establish that this file matches the color space, don't process it
  239. return false;
  240. }
  241. $profileRegex = '/' . preg_quote( $data[1], '/' ) . '/i';
  242. if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) {
  243. // We can't establish that this file has the expected ICC profile, don't process it
  244. return false;
  245. }
  246. $command = Shell::command( $wgExiftool,
  247. '-overwrite_original',
  248. '-icc_profile<=' . $profileFilepath,
  249. $filepath
  250. );
  251. $result = $command
  252. ->includeStderr()
  253. ->execute();
  254. if ( $result->getExitCode() !== 0 ) {
  255. $this->logErrorForExternalProcess( $result->getExitCode(),
  256. $result->getStdout(),
  257. $command
  258. );
  259. return false;
  260. }
  261. return true;
  262. }
  263. }