From 325ab5f1c512433ca7cb2d6b7aac9aadd9c7985d Mon Sep 17 00:00:00 2001 From: AttackVectorDelta Date: Tue, 21 Mar 2023 13:14:52 +0100 Subject: [PATCH] MDL-45910 core: added rotation of file thumbnails and profile pictures --- lib/filestorage/stored_file.php | 32 ++++- lib/filestorage/tests/stored_file_test.php | 93 ++++++++++++ lib/gdlib.php | 28 ++++ lib/tests/gdlib_test.php | 158 ++++++++++++++++++++- 4 files changed, 309 insertions(+), 2 deletions(-) diff --git a/lib/filestorage/stored_file.php b/lib/filestorage/stored_file.php index 8bd5bd4d247f..6be368dcb9d1 100644 --- a/lib/filestorage/stored_file.php +++ b/lib/filestorage/stored_file.php @@ -1101,7 +1101,7 @@ public function generate_image_thumbnail($width, $height) { global $CFG; require_once($CFG->libdir . '/gdlib.php'); - if (empty($width) or empty($height)) { + if (empty($width) || empty($height)) { return false; } @@ -1116,6 +1116,36 @@ public function generate_image_thumbnail($width, $height) { // Create a new image from the file. $original = @imagecreatefromstring($content); + // If image is of type JPEG and EXIF functions are avaliable, attempt rotation. + if ($imageinfo[2] == IMAGETYPE_JPEG && function_exists("exif_read_data")) { + $rotation = [ + 1 => 0, + 3 => 180, + 6 => 270, + 8 => 90, + ]; + + $exif = @exif_read_data("data://image/jpeg;base64," . base64_encode($content)); + + // If image orientation is present, valid and not equal to 1 (no rotation + // required) perform rotation. + if ( + isset($exif['Orientation']) && + array_key_exists($exif['Orientation'], $rotation) && + $exif['Orientation'] !== 1 + ) { + $original = @imagerotate($original, $rotation[$exif['Orientation']], 0); + + // If orientation value is 6 or 8, manually swap image width and height data. + if ($exif['Orientation'] == 6 || $exif['Orientation'] == 8) { + $tempvar = $imageinfo[0]; + $imageinfo[0] = $imageinfo[1]; + $imageinfo[1] = $tempvar; + $imageinfo[3] = "width=\"$imageinfo[0]\" height=\"$imageinfo[1]\""; + } + } + } + // Generate the thumbnail. return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height); } diff --git a/lib/filestorage/tests/stored_file_test.php b/lib/filestorage/tests/stored_file_test.php index ef61d16ca631..5d51d2677205 100644 --- a/lib/filestorage/tests/stored_file_test.php +++ b/lib/filestorage/tests/stored_file_test.php @@ -320,6 +320,99 @@ public static function incorrect_orientation_images_provider(): array { ]; } + /** + * Test that the generate_image_thumbnail() method correctly rotates and generates an + * image thumbail based on the source image EXIF data. + * + * @covers ::generate_image_thumbnail() + * @dataProvider thumbnail_images_provider + * + * @param int $controlangle Angle to be used when generating a control image + * @param string $imagefolder Folder that contains the required image + * @param string $imagename Filename of required image + * @param int $imageitemid ID of created item + */ + public function test_generate_rotated_image_thumbnail(int $controlangle, string $imagefolder, + string $imagename, int $imageitemid): void { + $this->resetAfterTest(true); + if (!function_exists("exif_read_data")) { + $this->markTestSkipped('This test requires exif support.'); + } + + global $CFG; + require_once($CFG->libdir . '/gdlib.php'); + + // Get stored file with orientation set to 1. + $controlfile = self::get_stored_file($imagefolder, $imagename, $imageitemid); + $control = imagecreatefromstring($controlfile->get_content()); + $controlimageinfo = getimagesizefromstring($controlfile->get_content()); + + // Use imagerotate function to get control image with expected rotation. + $controlrotated = imagerotate($control, $controlangle, 0); + + if ($controlangle == 90 || $controlangle == 270) { + $tempvar = $controlimageinfo[0]; + $controlimageinfo[0] = $controlimageinfo[1]; + $controlimageinfo[1] = $tempvar; + $controlimageinfo[3] = "width=\"$controlimageinfo[0]\" height=\"$controlimageinfo[1]\""; + } + + $controlrotatedthumbnail = generate_image_thumbnail_from_image($controlrotated, $controlimageinfo, 100, 100); + + $thumbnail = $controlfile->generate_image_thumbnail(100, 100); + + // Assert that $thumbnail was returned. + $this->assertFalse(empty($thumbnail)); + + ob_start(); + imagejpeg(imagecreatefromstring($controlrotatedthumbnail)); + $contentsexpected = ob_get_clean(); + + ob_start(); + imagejpeg(imagecreatefromstring($thumbnail)); + $contentsactual = ob_get_clean(); + + // Assert that thumbail created with generate_image_thumbnail() matches the one crated manually. + $this->assertEquals($contentsexpected, $contentsactual); + } + + /** + * Data provider for test_generate_rotated_image_thumbnail(). + * + * @return array + */ + public static function thumbnail_images_provider(): array { + return [ + [0, "minEXIF/h", "JPEG1.jpeg", 1], + [180, "minEXIF/h", "JPEG3.jpeg", 2], + [270, "minEXIF/h", "JPEG6.jpeg", 3], + [90, "minEXIF/h", "JPEG8.jpeg", 4], + [0, "minEXIF/v", "JPEG1.jpeg", 5], + [180, "minEXIF/v", "JPEG3.jpeg", 6], + [270, "minEXIF/v", "JPEG6.jpeg", 7], + [90, "minEXIF/v", "JPEG8.jpeg", 8], + [0, "fullEXIF/h", "JPEG1.jpeg", 9], + [180, "fullEXIF/h", "JPEG3.jpeg", 10], + [270, "fullEXIF/h", "JPEG6.jpeg", 11], + [90, "fullEXIF/h", "JPEG8.jpeg", 12], + [0, "fullEXIF/v", "JPEG1.jpeg", 13], + [180, "fullEXIF/v", "JPEG3.jpeg", 14], + [270, "fullEXIF/v", "JPEG6.jpeg", 15], + [90, "fullEXIF/v", "JPEG8.jpeg", 16], + [0, "incorrectEXIF/h", "JPEG0.jpeg", 17], + [0, "incorrectEXIF/h", "JPEG10.jpeg", 18], + [0, "incorrectEXIF/h", "JPEG0MissingEXIFH.jpeg", 19], + [0, "incorrectEXIF/h", "JPEG10MissingEXIFH.jpeg", 20], + [0, "incorrectEXIF/v", "JPEG0.jpeg", 21], + [0, "incorrectEXIF/v", "JPEG0MissingEXIFH.jpeg", 22], + [180, "partEXIF/h", "JPEGMissingEXIFH.jpeg", 23], + [180, "partEXIF/h", "JPEGMissingEXIFW.jpeg", 24], + [180, "partEXIF/v", "JPEGMissingEXIFH.jpeg", 25], + [0, "noEXIF/h", "JPEG1.jpeg", 27], + [0, "noEXIF/v", "JPEG1.jpeg", 28], + ]; + } + /** * Ensure that get_content_file_handle returns a valid file handle. * diff --git a/lib/gdlib.php b/lib/gdlib.php index bd69ece35faf..f9945fc6bd29 100644 --- a/lib/gdlib.php +++ b/lib/gdlib.php @@ -138,6 +138,34 @@ function process_new_icon($context, $component, $filearea, $itemid, $originalfil case IMAGETYPE_JPEG: if (function_exists('imagecreatefromjpeg')) { $im = imagecreatefromjpeg($originalfile); + + // If EXIF functions are avaliable, attempt rotation. + if (function_exists("exif_read_data")) { + $rotation = [ + 1 => 0, + 3 => 180, + 6 => 270, + 8 => 90, + ]; + + $exif = @exif_read_data($originalfile); + + // If image orientation is present, valid and not equal to 1 (no rotation + // required) perform rotation. + if ( + isset($exif['Orientation']) && + array_key_exists($exif['Orientation'], $rotation) && + $exif['Orientation'] !== 1 + ) { + $im = imagerotate($im, $rotation[$exif['Orientation']], 0); + + // If orientation value is 6 or 8, update image width and height data. + if ($exif['Orientation'] == 6 || $exif['Orientation'] == 8) { + $image->width = $imageinfo[1]; + $image->height = $imageinfo[0]; + } + } + } } else { debugging('JPEG not supported on this server'); return false; diff --git a/lib/tests/gdlib_test.php b/lib/tests/gdlib_test.php index d322d33d5cf6..c85bda3d58f8 100644 --- a/lib/tests/gdlib_test.php +++ b/lib/tests/gdlib_test.php @@ -24,7 +24,7 @@ * @copyright 2015 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class gdlib_test extends \basic_testcase { +class gdlib_test extends \advanced_testcase { private $fixturepath = null; @@ -144,4 +144,160 @@ public function test_resize_image_from_image() { $this->assertEquals('image/png', $imageinfo['mime']); } + /** + * Test that the process_new_icon() method correctly rotates and generates an + * icon based on the source image EXIF data. + * + * @covers ::process_new_icon() + * @dataProvider icon_images_provider + * + * @param int $controlangle Angle to be used when rotating the control image + * @param string $imagepath Path to the required image + * @param int $imageitemid ID of created item + */ + public function test_rotation_process_new_icon(int $controlangle, string $imagepath, int $imageitemid): void { + $this->resetAfterTest(); + + // Check if required JPEG functions for this test exist. + if (!function_exists('imagecreatefromjpeg')) { + $this->markTestSkipped('JPEG not supported on this server.'); + } + + // Check if required EXIF functions for this test exist. + if (!function_exists("exif_read_data")) { + $this->markTestSkipped('This test requires exif support.'); + } + + // Check if the given file exists. + if (!is_file($imagepath)) { + $this->markTestSkipped('Required fixture image does not exist.'); + } + + // Require libs. + global $CFG; + require_once($CFG->libdir . '/gdlib.php'); + + $fs = get_file_storage(); + + // Get image data from given filepath. + $imageinfo = getimagesize($imagepath); + + // Save some info in a better format for easier use later. + $image = new \stdClass(); + $image->width = $imageinfo[0]; + $image->height = $imageinfo[1]; + + if ($controlangle == 90 || $controlangle == 270) { + $image->width = $imageinfo[1]; + $image->height = $imageinfo[0]; + } + + // Make and rotate a control image to be used for comparison. + $control = imagecreatefromjpeg($imagepath); + $control = imagerotate($control, $controlangle, 0); + + // Create blank 100x100 image. + if (function_exists('imagecreatetruecolor')) { + $control1 = imagecreatetruecolor(100, 100); + } else { + $control1 = imagecreate(100, 100); + } + + // Calculate copy coordinates. + $cx = floor($image->width / 2); + $cy = floor($image->height / 2); + + if ($image->width < $image->height) { + $half = floor($image->width / 2.0); + } else { + $half = floor($image->height / 2.0); + } + + // Use imagecopybicubic() to resize control image. + imagecopybicubic($control1, $control, 0, 0, $cx - $half, $cy - $half, 100, 100, $half * 2, $half * 2); + + // Stringify control image. + ob_start(); + imagejpeg($control1, null, 90); + $contentsexpected = ob_get_clean(); + + // Save control image to file system. + $fsdata = [ + 'contextid' => \context_user::instance(2, MUST_EXIST)->id, + 'component' => 'user', + 'filearea' => 'icon', + 'itemid' => 100, + 'filepath' => '/', + 'filename' => 'f1.jpg', + ]; + $fs->delete_area_files($fsdata['contextid'], $fsdata['component'], $fsdata['filearea'], $fsdata['itemid']); + $controlsaved = $fs->create_file_from_string($fsdata, $contentsexpected); + + // Use process_new_icon() function to create a new icon. + $iconid = process_new_icon( + \context_user::instance(2, MUST_EXIST), + 'user', + 'icon', + $imageitemid, + $imagepath + ); + + // Assert that $iconid was returned. + $this->assertTrue($iconid !== false); + + // Fetch created icon by file ID. + $icon = $fs->get_file_by_id($iconid); + + // Stringify control image returned by create_file_from_string. + ob_start(); + imagejpeg(imagecreatefromstring($controlsaved->get_content()), null, 90); + $contentsexpected = ob_get_clean(); + + // Stringify created icon. + ob_start(); + imagejpeg(imagecreatefromstring($icon->get_content()), null, 90); + $contentsactual = ob_get_clean(); + + // Assert that icon created with process_new_icon() matches the one created manually. + $this->assertEquals($contentsexpected, $contentsactual); + } + + /** + * Data provider for test_rotation_process_new_icon(). + * + * @return array + */ + public static function icon_images_provider(): array { + global $CFG; + + return [ + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/h/JPEG1.jpeg", 1], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/h/JPEG3.jpeg", 2], + [270, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/h/JPEG6.jpeg", 3], + [90, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/h/JPEG8.jpeg", 4], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/v/JPEG1.jpeg", 5], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/v/JPEG3.jpeg", 6], + [270, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/v/JPEG6.jpeg", 7], + [90, $CFG->dirroot . "/lib/filestorage/tests/fixtures/minEXIF/v/JPEG8.jpeg", 8], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/h/JPEG1.jpeg", 9], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/h/JPEG3.jpeg", 10], + [270, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/h/JPEG6.jpeg", 11], + [90, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/h/JPEG8.jpeg", 12], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/v/JPEG1.jpeg", 13], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/v/JPEG3.jpeg", 14], + [270, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/v/JPEG6.jpeg", 15], + [90, $CFG->dirroot . "/lib/filestorage/tests/fixtures/fullEXIF/v/JPEG8.jpeg", 16], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/h/JPEG0.jpeg", 17], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/h/JPEG10.jpeg", 18], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/h/JPEG0MissingEXIFH.jpeg", 19], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/h/JPEG10MissingEXIFH.jpeg", 20], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/v/JPEG0.jpeg", 21], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/incorrectEXIF/v/JPEG0MissingEXIFH.jpeg", 22], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/partEXIF/h/JPEGMissingEXIFH.jpeg", 23], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/partEXIF/h/JPEGMissingEXIFW.jpeg", 24], + [180, $CFG->dirroot . "/lib/filestorage/tests/fixtures/partEXIF/v/JPEGMissingEXIFH.jpeg", 25], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/noEXIF/h/JPEG1.jpeg", 26], + [0, $CFG->dirroot . "/lib/filestorage/tests/fixtures/noEXIF/v/JPEG1.jpeg", 27], + ]; + } }