Skip to content

Commit

Permalink
MDL-45910 core: added rotation of file thumbnails and profile pictures
Browse files Browse the repository at this point in the history
  • Loading branch information
AttackVectorDelta committed Feb 22, 2024
1 parent 0ccc153 commit 3a1be85
Show file tree
Hide file tree
Showing 4 changed files with 309 additions and 2 deletions.
32 changes: 31 additions & 1 deletion lib/filestorage/stored_file.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
}
Expand Down
93 changes: 93 additions & 0 deletions lib/filestorage/tests/stored_file_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
28 changes: 28 additions & 0 deletions lib/gdlib.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
158 changes: 157 additions & 1 deletion lib/tests/gdlib_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* @copyright 2015 Andrew Nicols <[email protected]>
* @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;

Expand Down Expand Up @@ -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],
];
}
}

0 comments on commit 3a1be85

Please sign in to comment.