3 namespace Drupal\Tests\imagemagick\Functional;
5 use Drupal\Core\Image\ImageInterface;
6 use Drupal\Tests\TestFileCreationTrait;
7 use Drupal\Tests\BrowserTestBase;
10 * Tests that core image manipulations work properly through Imagemagick.
14 class ToolkitImagemagickTest extends BrowserTestBase {
16 use TestFileCreationTrait;
19 * The image factory service.
21 * @var \Drupal\Core\Image\ImageFactory
23 protected $imageFactory;
26 * A directory for image test file results.
30 protected $testDirectory;
32 // Colors that are used in testing.
33 // @codingStandardsIgnoreStart
34 protected $black = [ 0, 0, 0, 0];
35 protected $red = [255, 0, 0, 0];
36 protected $green = [ 0, 255, 0, 0];
37 protected $blue = [ 0, 0, 255, 0];
38 protected $yellow = [255, 255, 0, 0];
39 protected $fuchsia = [255, 0, 255, 0];
40 protected $cyan = [ 0, 255, 255, 0];
41 protected $white = [255, 255, 255, 0];
42 protected $grey = [128, 128, 128, 0];
43 protected $transparent = [ 0, 0, 0, 127];
44 protected $rotateTransparent = [255, 255, 255, 127];
46 protected $width = 40;
47 protected $height = 20;
48 // @codingStandardsIgnoreEnd
55 protected static $modules = [
65 public function setUp() {
68 // Create an admin user.
69 $admin_user = $this->drupalCreateUser([
70 'administer site configuration',
72 $this->drupalLogin($admin_user);
74 // Set the image factory.
75 $this->imageFactory = $this->container->get('image.factory');
77 // Prepare a directory for test file results.
78 $this->testDirectory = 'public://imagetest';
82 * Provides data for testManipulations.
85 * A simple array of simple arrays, each having the following elements:
86 * - binaries to use for testing.
88 public function providerManipulationTest() {
96 * Test image toolkit operations.
98 * Since PHP can't visually check that our images have been manipulated
99 * properly, build a list of expected color values for each of the corners and
100 * the expected height and widths for the final images.
102 * @param string $binaries
103 * The graphics package binaries to use for testing.
105 * @dataProvider providerManipulationTest
107 public function testManipulations($binaries) {
108 // Change the toolkit.
109 \Drupal::configFactory()->getEditable('system.image')
110 ->set('toolkit', 'imagemagick')
113 // Execute tests with selected binaries.
114 // The test can only be executed if binaries are available on the shell
116 \Drupal::configFactory()->getEditable('imagemagick.settings')
118 ->set('binaries', $binaries)
119 ->set('quality', 100)
121 $status = \Drupal::service('image.toolkit.manager')->createInstance('imagemagick')->checkPath('');
122 if (!empty($status['errors'])) {
123 // Bots running automated test on d.o. do not have binaries installed,
124 // so the test will be skipped; it can be run locally where binaries are
126 $this->markTestSkipped("Tests for '{$binaries}' cannot run because the binaries are not available on the shell path.");
129 // Set the toolkit on the image factory.
130 $this->imageFactory->setToolkitId('imagemagick');
132 // Test that the image factory is set to use the Imagemagick toolkit.
133 $this->assertEqual($this->imageFactory->getToolkitId(), 'imagemagick', 'The image factory is set to use the \'imagemagick\' image toolkit.');
135 // Prepare directory.
136 file_unmanaged_delete_recursive($this->testDirectory);
137 file_prepare_directory($this->testDirectory, FILE_CREATE_DIRECTORY);
139 // Typically the corner colors will be unchanged. These colors are in the
140 // order of top-left, top-right, bottom-right, bottom-left.
148 // A list of files that will be tested.
152 'image-test-no-transparency.gif',
156 // Setup a list of tests to perform on each type.
159 'function' => 'resize',
160 'arguments' => ['width' => 20, 'height' => 10],
163 'corners' => $default_corners,
167 'function' => 'scale',
168 'arguments' => ['width' => 20],
171 'corners' => $default_corners,
175 'function' => 'scale',
176 'arguments' => ['height' => 10],
179 'corners' => $default_corners,
183 'function' => 'scale',
184 'arguments' => ['width' => 80, 'upscale' => TRUE],
187 'corners' => $default_corners,
191 'function' => 'scale',
192 'arguments' => ['height' => 40, 'upscale' => TRUE],
195 'corners' => $default_corners,
199 'function' => 'crop',
200 'arguments' => ['x' => 12, 'y' => 4, 'width' => 16, 'height' => 12],
203 'corners' => array_fill(0, 4, $this->white),
206 'scale_and_crop' => [
207 'function' => 'scale_and_crop',
208 'arguments' => ['width' => 10, 'height' => 8],
211 'corners' => array_fill(0, 4, $this->black),
215 'function' => 'convert',
218 'arguments' => ['extension' => 'jpeg'],
219 'mimetype' => 'image/jpeg',
220 'corners' => $default_corners,
224 'function' => 'convert',
227 'arguments' => ['extension' => 'gif'],
228 'mimetype' => 'image/gif',
229 'corners' => $default_corners,
233 'function' => 'convert',
236 'arguments' => ['extension' => 'png'],
237 'mimetype' => 'image/png',
238 'corners' => $default_corners,
242 'function' => 'rotate',
245 'background' => '#FF00FF',
246 'resize_filter' => 'Box',
250 'corners' => array_fill(0, 4, $this->fuchsia),
253 'rotate_minus_10' => [
254 'function' => 'rotate',
257 'background' => '#FF00FF',
258 'resize_filter' => 'Box',
262 'corners' => array_fill(0, 4, $this->fuchsia),
266 'function' => 'rotate',
267 'arguments' => ['degrees' => 90, 'background' => '#FF00FF'],
270 'corners' => [$this->transparent, $this->red, $this->green, $this->blue],
273 'rotate_transparent_5' => [
274 'function' => 'rotate',
275 'arguments' => ['degrees' => 5, 'resize_filter' => 'Box'],
278 'corners' => array_fill(0, 4, $this->transparent),
281 'rotate_transparent_90' => [
282 'function' => 'rotate',
283 'arguments' => ['degrees' => 90],
286 'corners' => [$this->transparent, $this->red, $this->green, $this->blue],
290 'function' => 'desaturate',
294 // Grayscale corners are a bit funky. Each of the corners are a shade of
295 // gray. The values of these were determined simply by looking at the
296 // final image to see what desaturated colors end up being.
298 array_fill(0, 3, 76) + [3 => 0],
299 array_fill(0, 3, 149) + [3 => 0],
300 array_fill(0, 3, 29) + [3 => 0],
301 array_fill(0, 3, 225) + [3 => 127],
303 // @todo tolerance here is too high. Check reasons.
304 'tolerance' => 17000,
308 // Prepare a copy of test files.
309 $this->getTestFiles('image');
311 foreach ($files as $file) {
312 foreach ($operations as $op => $values) {
313 // Load up a fresh image.
314 $image = $this->imageFactory->get('public://' . $file);
315 if (!$image->isValid()) {
316 $this->fail("Could not load image $file.");
320 // Check that no multi-frame information is set.
321 $this->assertNull($image->getToolkit()->getFrames());
323 // Perform our operation.
324 $image->apply($values['function'], $values['arguments']);
326 // Save and reload image.
327 $file_path = $this->testDirectory . '/' . $op . substr($file, -4);
328 $image->save($file_path);
329 $image = $this->imageFactory->get($file_path);
330 $this->assertTrue($image->isValid());
332 // @todo GraphicsMagick specifics, temporarily adjust tests.
333 $package = $image->getToolkit()->getPackage();
334 if ($package === 'graphicsmagick') {
335 // @todo Issues with crop on GIF files, investigate.
336 if (in_array($file, ['image-test.gif', 'image-test-no-transparency.gif']) && in_array($op, ['crop', 'scale_and_crop'])) {
341 // Reload with GD to be able to check results at pixel level.
342 $image = $this->imageFactory->get($file_path, 'gd');
343 $toolkit = $image->getToolkit();
344 $toolkit->getResource();
345 $this->assertTrue($image->isValid());
347 // Check MIME type if needed.
348 if (isset($values['mimetype'])) {
349 $this->assertEqual($values['mimetype'], $toolkit->getMimeType(), "Image '$file' after '$op' action has proper MIME type ({$values['mimetype']}).");
352 // To keep from flooding the test with assert values, make a general
353 // value for whether each group of values fail.
354 $correct_dimensions_real = TRUE;
355 $correct_dimensions_object = TRUE;
357 // Check the real dimensions of the image first.
358 $actual_toolkit_width = imagesx($toolkit->getResource());
359 $actual_toolkit_height = imagesy($toolkit->getResource());
360 if ($actual_toolkit_height != $values['height'] || $actual_toolkit_width != $values['width']) {
361 $correct_dimensions_real = FALSE;
364 // Check that the image object has an accurate record of the dimensions.
365 $actual_image_width = $image->getWidth();
366 $actual_image_height = $image->getHeight();
367 if ($actual_image_width != $values['width'] || $actual_image_height != $values['height']) {
368 $correct_dimensions_object = FALSE;
371 $this->assertTrue($correct_dimensions_real, "Image '$file' after '$op' action has proper dimensions. Expected {$values['width']}x{$values['height']}, actual {$actual_toolkit_width}x{$actual_toolkit_height}.");
372 $this->assertTrue($correct_dimensions_object, "Image '$file' object after '$op' action is reporting the proper height and width values. Expected {$values['width']}x{$values['height']}, actual {$actual_image_width}x{$actual_image_height}.");
374 // JPEG colors will always be messed up due to compression.
375 if ($image->getToolkit()->getType() != IMAGETYPE_JPEG) {
376 // Now check each of the corners to ensure color correctness.
377 foreach ($values['corners'] as $key => $corner) {
378 // The test gif that does not have transparency has yellow where the
379 // others have transparent.
380 if ($file === 'image-test-no-transparency.gif' && $corner === $this->transparent && $op != 'rotate_transparent_5') {
381 $corner = $this->yellow;
383 // The test jpg when converted to other formats has yellow where the
384 // others have transparent.
385 if ($file === 'image-test.jpg' && $corner === $this->transparent && in_array($op, ['convert_gif', 'convert_png'])) {
386 $corner = $this->yellow;
388 // Get the location of the corner.
396 $x = $image->getWidth() - 1;
401 $x = $image->getWidth() - 1;
402 $y = $image->getHeight() - 1;
407 $y = $image->getHeight() - 1;
411 $color = $this->getPixelColor($image, $x, $y);
412 $correct_colors = $this->colorsAreClose($color, $corner, $values['tolerance']);
418 // Test creation of image from scratch, and saving to storage.
419 foreach ([IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_JPEG] as $type) {
420 $image = $this->imageFactory->get();
421 $image->createNew(50, 20, image_type_to_extension($type, FALSE), '#ffff00');
422 $file = 'from_null' . image_type_to_extension($type);
423 $file_path = $this->testDirectory . '/' . $file;
424 $this->assertEqual(50, $image->getWidth(), "Image file '$file' has the correct width.");
425 $this->assertEqual(20, $image->getHeight(), "Image file '$file' has the correct height.");
426 $this->assertEqual(image_type_to_mime_type($type), $image->getMimeType(), "Image file '$file' has the correct MIME type.");
427 $this->assertTrue($image->save($file_path), "Image '$file' created anew from a null image was saved.");
429 // Reload saved image.
430 $image_reloaded = $this->imageFactory->get($file_path, 'gd');
431 if (!$image_reloaded->isValid()) {
432 $this->fail("Could not load image '$file'.");
435 $this->assertEqual(50, $image_reloaded->getWidth(), "Image file '$file' has the correct width.");
436 $this->assertEqual(20, $image_reloaded->getHeight(), "Image file '$file' has the correct height.");
437 $this->assertEqual(image_type_to_mime_type($type), $image_reloaded->getMimeType(), "Image file '$file' has the correct MIME type.");
438 if ($image_reloaded->getToolkit()->getType() == IMAGETYPE_GIF) {
439 $this->assertEqual('#ffff00', $image_reloaded->getToolkit()->getTransparentColor(), "Image file '$file' has the correct transparent color channel set.");
442 $this->assertEqual(NULL, $image_reloaded->getToolkit()->getTransparentColor(), "Image file '$file' has no color channel set.");
446 // Test failures of CreateNew.
447 $image = $this->imageFactory->get();
448 $image->createNew(-50, 20);
449 $this->assertFalse($image->isValid(), 'CreateNew with negative width fails.');
450 $image->createNew(50, 20, 'foo');
451 $this->assertFalse($image->isValid(), 'CreateNew with invalid extension fails.');
452 $image->createNew(50, 20, 'gif', '#foo');
453 $this->assertFalse($image->isValid(), 'CreateNew with invalid color hex string fails.');
454 $image->createNew(50, 20, 'gif', '#ff0000');
455 $this->assertTrue($image->isValid(), 'CreateNew with valid arguments validates the Image.');
457 // Test saving image files with filenames having non-ascii characters.
459 'greek εικόνα δοκιμής.png',
460 'russian Тестовое изображение.png',
461 'simplified chinese 测试图片.png',
463 'arabic صورة الاختبار.png',
464 'armenian փորձարկման պատկերը.png',
465 'bengali পরীক্ষা ইমেজ.png',
466 'hebraic תמונת בדיקה.png',
467 'hindi परीक्षण छवि.png',
468 'viet hình ảnh thử nghiệm.png',
469 'viet \'with quotes\' hình ảnh thử nghiệm.png',
470 'viet "with double quotes" hình ảnh thử nghiệm.png',
472 foreach ($file_names as $file) {
473 $image = $this->imageFactory->get();
474 $image->createNew(50, 20, 'png');
475 $file_path = $this->testDirectory . '/' . $file;
476 $image->save($file_path);
477 $image_reloaded = $this->imageFactory->get($file_path, 'gd');
478 $this->assertTrue($image_reloaded->isValid(), "Image file '$file' loaded successfully.");
481 // Test handling a file stored through a remote stream wrapper.
482 $image = $this->imageFactory->get('dummy-remote://image-test.png');
483 // Source file should be equal to the copied local temp source file.
484 $this->assertEqual(filesize('dummy-remote://image-test.png'), filesize($image->getToolkit()->getSourceLocalPath()));
485 $image->desaturate();
486 $image->save('dummy-remote://remote-image-test.png');
487 // Destination file should exists, and destination local temp file should
489 $this->assertTrue(file_exists($image->getToolkit()->getDestination()));
490 $this->assertEqual('dummy-remote://remote-image-test.png', $image->getToolkit()->getDestination());
491 $this->assertIdentical('', $image->getToolkit()->getDestinationLocalPath());
493 // Test retrieval of EXIF information.
496 'path' => drupal_get_path('module', 'imagemagick') . '/misc/test-exif.jpeg',
500 'path' => 'public://image-test.jpg',
501 'orientation' => NULL,
504 'path' => 'public://image-test.png',
505 'orientation' => NULL,
508 'path' => 'public://image-test.gif',
509 'orientation' => NULL,
513 'orientation' => NULL,
517 foreach ($image_files as $image_file) {
518 // Get image using 'identify'.
519 \Drupal::configFactory()->getEditable('imagemagick.settings')
520 ->set('use_identify', TRUE)
522 $image = $this->imageFactory->get($image_file['path']);
523 $this->assertIdentical($image_file['orientation'], $image->getToolkit()->getExifOrientation());
525 // Get image using 'getimagesize'.
526 \Drupal::configFactory()->getEditable('imagemagick.settings')
527 ->set('use_identify', FALSE)
529 $image = $this->imageFactory->get($image_file['path']);
530 $this->assertIdentical($image_file['orientation'], $image->getToolkit()->getExifOrientation());
533 // Test multi-frame GIF image.
536 'source' => drupal_get_path('module', 'imagemagick') . '/misc/test-multi-frame.gif',
537 'destination' => $this->testDirectory . '/test-multi-frame.gif',
541 'scaled_width' => 30,
542 'scaled_height' => 15,
543 'rotated_width' => 33,
544 'rotated_height' => 26,
548 // Get images using 'identify'.
549 \Drupal::configFactory()->getEditable('imagemagick.settings')
550 ->set('use_identify', TRUE)
552 foreach ($image_files as $image_file) {
553 $image = $this->imageFactory->get($image_file['source']);
554 $this->assertIdentical($image_file['width'], $image->getWidth());
555 $this->assertIdentical($image_file['height'], $image->getHeight());
556 $this->assertIdentical($image_file['frames'], $image->getToolkit()->getFrames());
558 // Scaling should preserve frames.
560 $image->save($image_file['destination']);
561 $image = $this->imageFactory->get($image_file['destination']);
562 $this->assertIdentical($image_file['scaled_width'], $image->getWidth());
563 $this->assertIdentical($image_file['scaled_height'], $image->getHeight());
564 $this->assertIdentical($image_file['frames'], $image->getToolkit()->getFrames());
566 // Rotating should preserve frames.
568 $image->save($image_file['destination']);
569 $image = $this->imageFactory->get($image_file['destination']);
570 $this->assertIdentical($image_file['rotated_width'], $image->getWidth());
571 $this->assertIdentical($image_file['rotated_height'], $image->getHeight());
572 $this->assertIdentical($image_file['frames'], $image->getToolkit()->getFrames());
574 // Converting to PNG should drop frames.
575 $image->convert('png');
576 $this->assertNull($image->getToolkit()->getFrames());
577 $image->save($image_file['destination']);
578 $image = $this->imageFactory->get($image_file['destination']);
579 $this->assertIdentical($image_file['rotated_width'], $image->getWidth());
580 $this->assertIdentical($image_file['rotated_height'], $image->getHeight());
581 $this->assertNull($image->getToolkit()->getFrames());
586 * Test ImageMagick subform and settings.
588 public function testFormAndSettings() {
589 // Change the toolkit.
590 \Drupal::configFactory()->getEditable('system.image')
591 ->set('toolkit', 'imagemagick')
594 // Test form is accepting wrong binaries path while setting toolkit to GD.
595 $this->drupalGet('admin/config/media/image-toolkit');
596 $this->assertFieldByName('image_toolkit', 'imagemagick');
598 'image_toolkit' => 'gd',
599 'imagemagick[suite][path_to_binaries]' => '/foo/bar',
601 $this->drupalPostForm(NULL, $edit, 'Save configuration');
602 $this->assertFieldByName('image_toolkit', 'gd');
604 // Change the toolkit.
605 \Drupal::configFactory()->getEditable('system.image')
606 ->set('toolkit', 'imagemagick')
608 $this->imageFactory->setToolkitId('imagemagick');
609 $this->assertEqual('imagemagick', $this->imageFactory->getToolkitId());
611 // Test default supported image extensions.
612 $this->assertEqual('gif jpe jpeg jpg png', implode(' ', $this->imageFactory->getSupportedExtensions()));
614 $config = \Drupal::configFactory()->getEditable('imagemagick.settings');
617 $image_formats = $config->get('image_formats');
618 $image_formats['TIFF']['enabled'] = TRUE;
619 $config->set('image_formats', $image_formats)->save();
620 $this->assertEqual('gif jpe jpeg jpg png tif tiff', implode(' ', $this->imageFactory->getSupportedExtensions()));
623 $image_formats['PNG']['enabled'] = FALSE;
624 $config->set('image_formats', $image_formats)->save();
625 $this->assertEqual('gif jpe jpeg jpg tif tiff', implode(' ', $this->imageFactory->getSupportedExtensions()));
627 // Disable some extensions.
628 $image_formats['TIFF']['exclude_extensions'] = 'tif, gif';
629 $config->set('image_formats', $image_formats)->save();
630 $this->assertEqual('gif jpe jpeg jpg tiff', implode(' ', $this->imageFactory->getSupportedExtensions()));
631 $image_formats['JPEG']['exclude_extensions'] = 'jpe, jpg';
632 $config->set('image_formats', $image_formats)->save();
633 $this->assertEqual('gif jpeg tiff', implode(' ', $this->imageFactory->getSupportedExtensions()));
637 * Function for finding a pixel's RGBa values.
639 protected function getPixelColor(ImageInterface $image, $x, $y) {
640 $toolkit = $image->getToolkit();
641 $color_index = imagecolorat($toolkit->getResource(), $x, $y);
643 $transparent_index = imagecolortransparent($toolkit->getResource());
644 if ($color_index == $transparent_index) {
645 return array(0, 0, 0, 127);
648 return array_values(imagecolorsforindex($toolkit->getResource(), $color_index));
652 * Function to compare two colors by RGBa, within a tolerance.
654 * Very basic, just compares the sum of the squared differences for each of
655 * the R, G, B, A components of two colors against a 'tolerance' value.
657 * @param int[] $actual
658 * The actual RGBA array.
659 * @param int[] $expected
660 * The expected RGBA array.
661 * @param int $tolerance
662 * The acceptable difference between the colors.
665 * TRUE if the colors differences are within tolerance, FALSE otherwise.
667 protected function colorsAreClose(array $actual, array $expected, $tolerance) {
668 // Fully transparent colors are equal, regardless of RGB.
669 if ($actual[3] == 127 && $expected[3] == 127) {
672 $distance = pow(($actual[0] - $expected[0]), 2) + pow(($actual[1] - $expected[1]), 2) + pow(($actual[2] - $expected[2]), 2) + pow(($actual[3] - $expected[3]), 2);
673 $this->assertLessThanOrEqual($tolerance, $distance, "Actual: {" . implode(',', $actual) . "}, Expected: {" . implode(',', $expected) . "}, Distance: " . $distance . ", Tolerance: " . $tolerance);