ToolkitGdTest.php 21 KB

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