ToolkitGdTest.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <?php
  2. namespace Drupal\KernelTests\Core\Image;
  3. use Drupal\Core\File\FileSystemInterface;
  4. use Drupal\Core\Image\ImageInterface;
  5. use Drupal\Component\Render\FormattableMarkup;
  6. use Drupal\Core\Site\Settings;
  7. use Drupal\KernelTests\KernelTestBase;
  8. /**
  9. * Tests that core image manipulations work properly: scale, resize, rotate,
  10. * crop, scale and crop, and desaturate.
  11. *
  12. * @group Image
  13. */
  14. class ToolkitGdTest extends KernelTestBase {
  15. /**
  16. * The image factory service.
  17. *
  18. * @var \Drupal\Core\Image\ImageFactory
  19. */
  20. protected $imageFactory;
  21. // Colors that are used in testing.
  22. protected $black = [0, 0, 0, 0];
  23. protected $red = [255, 0, 0, 0];
  24. protected $green = [0, 255, 0, 0];
  25. protected $blue = [0, 0, 255, 0];
  26. protected $yellow = [255, 255, 0, 0];
  27. protected $white = [255, 255, 255, 0];
  28. protected $transparent = [0, 0, 0, 127];
  29. // Used as rotate background colors.
  30. protected $fuchsia = [255, 0, 255, 0];
  31. protected $rotateTransparent = [255, 255, 255, 127];
  32. protected $width = 40;
  33. protected $height = 20;
  34. /**
  35. * Modules to enable.
  36. *
  37. * @var array
  38. */
  39. public static $modules = ['system'];
  40. /**
  41. * {@inheritdoc}
  42. */
  43. protected function setUp() {
  44. parent::setUp();
  45. // Set the image factory service.
  46. $this->imageFactory = $this->container->get('image.factory');
  47. }
  48. protected function checkRequirements() {
  49. // GD2 support is available.
  50. if (!function_exists('imagegd2')) {
  51. return [
  52. 'Image manipulations for the GD toolkit cannot run because the GD toolkit is not available.',
  53. ];
  54. }
  55. return parent::checkRequirements();
  56. }
  57. /**
  58. * Function to compare two colors by RGBa.
  59. */
  60. public function colorsAreEqual($color_a, $color_b) {
  61. // Fully transparent pixels are equal, regardless of RGB.
  62. if ($color_a[3] == 127 && $color_b[3] == 127) {
  63. return TRUE;
  64. }
  65. foreach ($color_a as $key => $value) {
  66. if ($color_b[$key] != $value) {
  67. return FALSE;
  68. }
  69. }
  70. return TRUE;
  71. }
  72. /**
  73. * Function for finding a pixel's RGBa values.
  74. */
  75. public function getPixelColor(ImageInterface $image, $x, $y) {
  76. $toolkit = $image->getToolkit();
  77. $color_index = imagecolorat($toolkit->getResource(), $x, $y);
  78. $transparent_index = imagecolortransparent($toolkit->getResource());
  79. if ($color_index == $transparent_index) {
  80. return [0, 0, 0, 127];
  81. }
  82. return array_values(imagecolorsforindex($toolkit->getResource(), $color_index));
  83. }
  84. /**
  85. * Since PHP can't visually check that our images have been manipulated
  86. * properly, build a list of expected color values for each of the corners and
  87. * the expected height and widths for the final images.
  88. */
  89. public function testManipulations() {
  90. // Test that the image factory is set to use the GD toolkit.
  91. $this->assertEqual($this->imageFactory->getToolkitId(), 'gd', 'The image factory is set to use the \'gd\' image toolkit.');
  92. // Test the list of supported extensions.
  93. $expected_extensions = ['png', 'gif', 'jpeg', 'jpg', 'jpe'];
  94. $supported_extensions = $this->imageFactory->getSupportedExtensions();
  95. $this->assertEqual($expected_extensions, array_intersect($expected_extensions, $supported_extensions));
  96. // Test that the supported extensions map to correct internal GD image
  97. // types.
  98. $expected_image_types = [
  99. 'png' => IMAGETYPE_PNG,
  100. 'gif' => IMAGETYPE_GIF,
  101. 'jpeg' => IMAGETYPE_JPEG,
  102. 'jpg' => IMAGETYPE_JPEG,
  103. 'jpe' => IMAGETYPE_JPEG,
  104. ];
  105. $image = $this->imageFactory->get();
  106. foreach ($expected_image_types as $extension => $expected_image_type) {
  107. $image_type = $image->getToolkit()->extensionToImageType($extension);
  108. $this->assertSame($expected_image_type, $image_type);
  109. }
  110. // Typically the corner colors will be unchanged. These colors are in the
  111. // order of top-left, top-right, bottom-right, bottom-left.
  112. $default_corners = [$this->red, $this->green, $this->blue, $this->transparent];
  113. // A list of files that will be tested.
  114. $files = [
  115. 'image-test.png',
  116. 'image-test.gif',
  117. 'image-test-no-transparency.gif',
  118. 'image-test.jpg',
  119. ];
  120. // Setup a list of tests to perform on each type.
  121. $operations = [
  122. 'resize' => [
  123. 'function' => 'resize',
  124. 'arguments' => ['width' => 20, 'height' => 10],
  125. 'width' => 20,
  126. 'height' => 10,
  127. 'corners' => $default_corners,
  128. ],
  129. 'scale_x' => [
  130. 'function' => 'scale',
  131. 'arguments' => ['width' => 20],
  132. 'width' => 20,
  133. 'height' => 10,
  134. 'corners' => $default_corners,
  135. ],
  136. 'scale_y' => [
  137. 'function' => 'scale',
  138. 'arguments' => ['height' => 10],
  139. 'width' => 20,
  140. 'height' => 10,
  141. 'corners' => $default_corners,
  142. ],
  143. 'upscale_x' => [
  144. 'function' => 'scale',
  145. 'arguments' => ['width' => 80, 'upscale' => TRUE],
  146. 'width' => 80,
  147. 'height' => 40,
  148. 'corners' => $default_corners,
  149. ],
  150. 'upscale_y' => [
  151. 'function' => 'scale',
  152. 'arguments' => ['height' => 40, 'upscale' => TRUE],
  153. 'width' => 80,
  154. 'height' => 40,
  155. 'corners' => $default_corners,
  156. ],
  157. 'crop' => [
  158. 'function' => 'crop',
  159. 'arguments' => ['x' => 12, 'y' => 4, 'width' => 16, 'height' => 12],
  160. 'width' => 16,
  161. 'height' => 12,
  162. 'corners' => array_fill(0, 4, $this->white),
  163. ],
  164. 'scale_and_crop' => [
  165. 'function' => 'scale_and_crop',
  166. 'arguments' => ['width' => 10, 'height' => 8],
  167. 'width' => 10,
  168. 'height' => 8,
  169. 'corners' => array_fill(0, 4, $this->black),
  170. ],
  171. 'convert_jpg' => [
  172. 'function' => 'convert',
  173. 'width' => 40,
  174. 'height' => 20,
  175. 'arguments' => ['extension' => 'jpeg'],
  176. 'corners' => $default_corners,
  177. ],
  178. 'convert_gif' => [
  179. 'function' => 'convert',
  180. 'width' => 40,
  181. 'height' => 20,
  182. 'arguments' => ['extension' => 'gif'],
  183. 'corners' => $default_corners,
  184. ],
  185. 'convert_png' => [
  186. 'function' => 'convert',
  187. 'width' => 40,
  188. 'height' => 20,
  189. 'arguments' => ['extension' => 'png'],
  190. 'corners' => $default_corners,
  191. ],
  192. ];
  193. // Systems using non-bundled GD2 don't have imagerotate. Test if available.
  194. // @todo Remove the version check once
  195. // https://www.drupal.org/project/drupal/issues/2670966 is resolved.
  196. if (function_exists('imagerotate') && (version_compare(phpversion(), '7.0.26') < 0)) {
  197. $operations += [
  198. 'rotate_5' => [
  199. 'function' => 'rotate',
  200. // Fuchsia background.
  201. 'arguments' => ['degrees' => 5, 'background' => '#FF00FF'],
  202. 'width' => 41,
  203. 'height' => 23,
  204. 'corners' => array_fill(0, 4, $this->fuchsia),
  205. ],
  206. 'rotate_90' => [
  207. 'function' => 'rotate',
  208. // Fuchsia background.
  209. 'arguments' => ['degrees' => 90, 'background' => '#FF00FF'],
  210. 'width' => 20,
  211. 'height' => 40,
  212. 'corners' => [$this->transparent, $this->red, $this->green, $this->blue],
  213. ],
  214. 'rotate_transparent_5' => [
  215. 'function' => 'rotate',
  216. 'arguments' => ['degrees' => 5],
  217. 'width' => 41,
  218. 'height' => 23,
  219. 'corners' => array_fill(0, 4, $this->rotateTransparent),
  220. ],
  221. 'rotate_transparent_90' => [
  222. 'function' => 'rotate',
  223. 'arguments' => ['degrees' => 90],
  224. 'width' => 20,
  225. 'height' => 40,
  226. 'corners' => [$this->transparent, $this->red, $this->green, $this->blue],
  227. ],
  228. ];
  229. }
  230. // Systems using non-bundled GD2 don't have imagefilter. Test if available.
  231. if (function_exists('imagefilter')) {
  232. $operations += [
  233. 'desaturate' => [
  234. 'function' => 'desaturate',
  235. 'arguments' => [],
  236. 'height' => 20,
  237. 'width' => 40,
  238. // Grayscale corners are a bit funky. Each of the corners are a shade of
  239. // gray. The values of these were determined simply by looking at the
  240. // final image to see what desaturated colors end up being.
  241. 'corners' => [
  242. array_fill(0, 3, 76) + [3 => 0],
  243. array_fill(0, 3, 149) + [3 => 0],
  244. array_fill(0, 3, 29) + [3 => 0],
  245. array_fill(0, 3, 225) + [3 => 127],
  246. ],
  247. ],
  248. ];
  249. }
  250. // Prepare a directory for test file results.
  251. $directory = Settings::get('file_public_path') . '/imagetest';
  252. \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
  253. foreach ($files as $file) {
  254. foreach ($operations as $op => $values) {
  255. // Load up a fresh image.
  256. $image = $this->imageFactory->get('core/tests/fixtures/files/' . $file);
  257. $toolkit = $image->getToolkit();
  258. if (!$image->isValid()) {
  259. $this->fail(new FormattableMarkup('Could not load image %file.', ['%file' => $file]));
  260. continue 2;
  261. }
  262. $image_original_type = $image->getToolkit()->getType();
  263. // All images should be converted to truecolor when loaded.
  264. $image_truecolor = imageistruecolor($toolkit->getResource());
  265. $this->assertTrue($image_truecolor, new FormattableMarkup('Image %file after load is a truecolor image.', ['%file' => $file]));
  266. // Store the original GD resource.
  267. $old_res = $toolkit->getResource();
  268. // Perform our operation.
  269. $image->apply($values['function'], $values['arguments']);
  270. // If the operation replaced the resource, check that the old one has
  271. // been destroyed.
  272. $new_res = $toolkit->getResource();
  273. if ($new_res !== $old_res) {
  274. // @todo In https://www.drupal.org/node/3133236 convert this to
  275. // $this->assertIsNotResource($old_res).
  276. $this->assertFalse(is_resource($old_res), new FormattableMarkup("'%operation' destroyed the original resource.", ['%operation' => $values['function']]));
  277. }
  278. // To keep from flooding the test with assert values, make a general
  279. // value for whether each group of values fail.
  280. $correct_dimensions_real = TRUE;
  281. $correct_dimensions_object = TRUE;
  282. if (imagesy($toolkit->getResource()) != $values['height'] || imagesx($toolkit->getResource()) != $values['width']) {
  283. $correct_dimensions_real = FALSE;
  284. }
  285. // Check that the image object has an accurate record of the dimensions.
  286. if ($image->getWidth() != $values['width'] || $image->getHeight() != $values['height']) {
  287. $correct_dimensions_object = FALSE;
  288. }
  289. $file_path = $directory . '/' . $op . image_type_to_extension($image->getToolkit()->getType());
  290. $image->save($file_path);
  291. $this->assertTrue($correct_dimensions_real, new FormattableMarkup('Image %file after %action action has proper dimensions.', ['%file' => $file, '%action' => $op]));
  292. $this->assertTrue($correct_dimensions_object, new FormattableMarkup('Image %file object after %action action is reporting the proper height and width values.', ['%file' => $file, '%action' => $op]));
  293. // JPEG colors will always be messed up due to compression. So we skip
  294. // these tests if the original or the result is in jpeg format.
  295. if ($image->getToolkit()->getType() != IMAGETYPE_JPEG && $image_original_type != IMAGETYPE_JPEG) {
  296. // Now check each of the corners to ensure color correctness.
  297. foreach ($values['corners'] as $key => $corner) {
  298. // The test gif that does not have transparency color set is a
  299. // special case.
  300. if ($file === 'image-test-no-transparency.gif') {
  301. if ($op == 'desaturate') {
  302. // For desaturating, keep the expected color from the test
  303. // data, but set alpha channel to fully opaque.
  304. $corner[3] = 0;
  305. }
  306. elseif ($corner === $this->transparent) {
  307. // Set expected pixel to yellow where the others have
  308. // transparent.
  309. $corner = $this->yellow;
  310. }
  311. }
  312. // Get the location of the corner.
  313. switch ($key) {
  314. case 0:
  315. $x = 0;
  316. $y = 0;
  317. break;
  318. case 1:
  319. $x = $image->getWidth() - 1;
  320. $y = 0;
  321. break;
  322. case 2:
  323. $x = $image->getWidth() - 1;
  324. $y = $image->getHeight() - 1;
  325. break;
  326. case 3:
  327. $x = 0;
  328. $y = $image->getHeight() - 1;
  329. break;
  330. }
  331. $color = $this->getPixelColor($image, $x, $y);
  332. // We also skip the color test for transparency for gif <-> png
  333. // conversion. The convert operation cannot handle that correctly.
  334. if ($image->getToolkit()->getType() == $image_original_type || $corner != $this->transparent) {
  335. $correct_colors = $this->colorsAreEqual($color, $corner);
  336. $this->assertTrue($correct_colors, new FormattableMarkup('Image %file object after %action action has the correct color placement at corner %corner.',
  337. ['%file' => $file, '%action' => $op, '%corner' => $key]));
  338. }
  339. }
  340. }
  341. // Check that saved image reloads without raising PHP errors.
  342. $image_reloaded = $this->imageFactory->get($file_path);
  343. $resource = $image_reloaded->getToolkit()->getResource();
  344. }
  345. }
  346. // Test creation of image from scratch, and saving to storage.
  347. foreach ([IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_JPEG] as $type) {
  348. $image = $this->imageFactory->get();
  349. $image->createNew(50, 20, image_type_to_extension($type, FALSE), '#ffff00');
  350. $file = 'from_null' . image_type_to_extension($type);
  351. $file_path = $directory . '/' . $file;
  352. $this->assertEqual(50, $image->getWidth(), new FormattableMarkup('Image file %file has the correct width.', ['%file' => $file]));
  353. $this->assertEqual(20, $image->getHeight(), new FormattableMarkup('Image file %file has the correct height.', ['%file' => $file]));
  354. $this->assertEqual(image_type_to_mime_type($type), $image->getMimeType(), new FormattableMarkup('Image file %file has the correct MIME type.', ['%file' => $file]));
  355. $this->assertTrue($image->save($file_path), new FormattableMarkup('Image %file created anew from a null image was saved.', ['%file' => $file]));
  356. // Reload saved image.
  357. $image_reloaded = $this->imageFactory->get($file_path);
  358. if (!$image_reloaded->isValid()) {
  359. $this->fail(new FormattableMarkup('Could not load image %file.', ['%file' => $file]));
  360. continue;
  361. }
  362. $this->assertEqual(50, $image_reloaded->getWidth(), new FormattableMarkup('Image file %file has the correct width.', ['%file' => $file]));
  363. $this->assertEqual(20, $image_reloaded->getHeight(), new FormattableMarkup('Image file %file has the correct height.', ['%file' => $file]));
  364. $this->assertEqual(image_type_to_mime_type($type), $image_reloaded->getMimeType(), new FormattableMarkup('Image file %file has the correct MIME type.', ['%file' => $file]));
  365. if ($image_reloaded->getToolkit()->getType() == IMAGETYPE_GIF) {
  366. $this->assertEqual('#ffff00', $image_reloaded->getToolkit()->getTransparentColor(), new FormattableMarkup('Image file %file has the correct transparent color channel set.', ['%file' => $file]));
  367. }
  368. else {
  369. $this->assertEqual(NULL, $image_reloaded->getToolkit()->getTransparentColor(), new FormattableMarkup('Image file %file has no color channel set.', ['%file' => $file]));
  370. }
  371. }
  372. // Test failures of the 'create_new' operation.
  373. $image = $this->imageFactory->get();
  374. $image->createNew(-50, 20);
  375. $this->assertFalse($image->isValid(), 'CreateNew with negative width fails.');
  376. $image->createNew(50, 20, 'foo');
  377. $this->assertFalse($image->isValid(), 'CreateNew with invalid extension fails.');
  378. $image->createNew(50, 20, 'gif', '#foo');
  379. $this->assertFalse($image->isValid(), 'CreateNew with invalid color hex string fails.');
  380. $image->createNew(50, 20, 'gif', '#ff0000');
  381. $this->assertTrue($image->isValid(), 'CreateNew with valid arguments validates the Image.');
  382. }
  383. /**
  384. * Tests that GD resources are freed from memory.
  385. */
  386. public function testResourceDestruction() {
  387. // Test that an Image object going out of scope releases its GD resource.
  388. $image = $this->imageFactory->get('core/tests/fixtures/files/image-test.png');
  389. $res = $image->getToolkit()->getResource();
  390. $this->assertIsResource($res);
  391. $image = NULL;
  392. // @todo In https://www.drupal.org/node/3133236 convert this to
  393. // $this->assertIsNotResource($res).
  394. $this->assertFalse(is_resource($res), 'Image resource was destroyed after losing scope.');
  395. // Test that 'create_new' operation does not leave orphaned GD resources.
  396. $image = $this->imageFactory->get('core/tests/fixtures/files/image-test.png');
  397. $old_res = $image->getToolkit()->getResource();
  398. // Check if resource has been created successfully.
  399. $this->assertIsResource($old_res);
  400. $image->createNew(20, 20);
  401. $new_res = $image->getToolkit()->getResource();
  402. // Check if the original resource has been destroyed.
  403. // @todo In https://www.drupal.org/node/3133236 convert this to
  404. // $this->assertIsNotResource($old_res).
  405. $this->assertFalse(is_resource($old_res));
  406. // Check if a new resource has been created successfully.
  407. $this->assertIsResource($new_res);
  408. }
  409. /**
  410. * Tests for GIF images with transparency.
  411. */
  412. public function testGifTransparentImages() {
  413. // Prepare a directory for test file results.
  414. $directory = Settings::get('file_public_path') . '/imagetest';
  415. \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
  416. // Test loading an indexed GIF image with transparent color set.
  417. // Color at top-right pixel should be fully transparent.
  418. $file = 'image-test-transparent-indexed.gif';
  419. $image = $this->imageFactory->get('core/tests/fixtures/files/' . $file);
  420. $resource = $image->getToolkit()->getResource();
  421. $color_index = imagecolorat($resource, $image->getWidth() - 1, 0);
  422. $color = array_values(imagecolorsforindex($resource, $color_index));
  423. $this->assertEqual($this->rotateTransparent, $color, "Image {$file} after load has full transparent color at corner 1.");
  424. // Test deliberately creating a GIF image with no transparent color set.
  425. // Color at top-right pixel should be fully transparent while in memory,
  426. // fully opaque after flushing image to file.
  427. $file = 'image-test-no-transparent-color-set.gif';
  428. $file_path = $directory . '/' . $file;
  429. // Create image.
  430. $image = $this->imageFactory->get();
  431. $image->createNew(50, 20, 'gif', NULL);
  432. $resource = $image->getToolkit()->getResource();
  433. $color_index = imagecolorat($resource, $image->getWidth() - 1, 0);
  434. $color = array_values(imagecolorsforindex($resource, $color_index));
  435. $this->assertEqual($this->rotateTransparent, $color, "New GIF image with no transparent color set after creation has full transparent color at corner 1.");
  436. // Save image.
  437. $this->assertTrue($image->save($file_path), "New GIF image {$file} was saved.");
  438. // Reload image.
  439. $image_reloaded = $this->imageFactory->get($file_path);
  440. $resource = $image_reloaded->getToolkit()->getResource();
  441. $color_index = imagecolorat($resource, $image_reloaded->getWidth() - 1, 0);
  442. $color = array_values(imagecolorsforindex($resource, $color_index));
  443. // Check explicitly for alpha == 0 as the rest of the color has been
  444. // compressed and may have slight difference from full white.
  445. $this->assertEqual(0, $color[3], "New GIF image {$file} after reload has no transparent color at corner 1.");
  446. // Test loading an image whose transparent color index is out of range.
  447. // This image was generated by taking an initial image with a palette size
  448. // of 6 colors, and setting the transparent color index to 6 (one higher
  449. // than the largest allowed index), as follows:
  450. // @code
  451. // $image = imagecreatefromgif('core/tests/fixtures/files/image-test.gif');
  452. // imagecolortransparent($image, 6);
  453. // imagegif($image, 'core/tests/fixtures/files/image-test-transparent-out-of-range.gif');
  454. // @endcode
  455. // This allows us to test that an image with an out-of-range color index
  456. // can be loaded correctly.
  457. $file = 'image-test-transparent-out-of-range.gif';
  458. $image = $this->imageFactory->get('core/tests/fixtures/files/' . $file);
  459. $toolkit = $image->getToolkit();
  460. if (!$image->isValid()) {
  461. $this->fail(new FormattableMarkup('Could not load image %file.', ['%file' => $file]));
  462. }
  463. else {
  464. // All images should be converted to truecolor when loaded.
  465. $image_truecolor = imageistruecolor($toolkit->getResource());
  466. $this->assertTrue($image_truecolor, new FormattableMarkup('Image %file after load is a truecolor image.', ['%file' => $file]));
  467. }
  468. }
  469. /**
  470. * Tests calling a missing image operation plugin.
  471. */
  472. public function testMissingOperation() {
  473. // Test that the image factory is set to use the GD toolkit.
  474. $this->assertEqual($this->imageFactory->getToolkitId(), 'gd', 'The image factory is set to use the \'gd\' image toolkit.');
  475. // An image file that will be tested.
  476. $file = 'image-test.png';
  477. // Load up a fresh image.
  478. $image = $this->imageFactory->get('core/tests/fixtures/files/' . $file);
  479. if (!$image->isValid()) {
  480. $this->fail(new FormattableMarkup('Could not load image %file.', ['%file' => $file]));
  481. }
  482. // Try perform a missing toolkit operation.
  483. $this->assertFalse($image->apply('missing_op', []), 'Calling a missing image toolkit operation plugin fails.');
  484. }
  485. }