diff --git a/files/classes/redactor/services/exifremover_service.php b/files/classes/redactor/services/exifremover_service.php index 12f62a5e361..9687e757ce0 100644 --- a/files/classes/redactor/services/exifremover_service.php +++ b/files/classes/redactor/services/exifremover_service.php @@ -63,6 +63,30 @@ class exifremover_service extends service implements file_redactor_service_inter /** @var bool $useexiftool Flag indicating whether to use ExifTool. */ private bool $useexiftool = false; + /** @var int Normal orientation (no rotation). */ + private const TOP_LEFT = 1; + + /** @var int Mirrored horizontally. */ + private const TOP_RIGHT = 2; + + /** @var int Rotated 180° (upside down). */ + private const BOTTOM_RIGHT = 3; + + /** @var int Mirrored vertically. */ + private const BOTTOM_LEFT = 4; + + /** @var int Mirrored horizontally and rotated 270° clockwise. */ + private const LEFT_TOP = 5; + + /** @var int Rotated 90° clockwise. */ + private const RIGHT_TOP = 6; + + /** @var int Mirrored horizontally and rotated 90° clockwise. */ + private const RIGHT_BOTTOM = 7; + + /** @var int Rotated 270° clockwise. */ + private const LEFT_BOTTOM = 8; + /** * Initialise the EXIF remover service. */ @@ -161,8 +185,12 @@ class exifremover_service extends service implements file_redactor_service_inter * @throws moodle_exception If the image data is not successfully recreated. */ private function execute_gd(string $sourcefile): string { + // Read EXIF data from the temporary file. + $exifdata = @exif_read_data($sourcefile); + $orientation = isset($exifdata['Orientation']) ? $exifdata['Orientation'] : self::TOP_LEFT; + $filecontent = file_get_contents($sourcefile); - $destinationfile = $this->recreate_image_gd($filecontent); + $destinationfile = $this->recreate_image_gd($filecontent, $orientation); if (!$destinationfile) { throw new moodle_exception( errorcode: 'redactor:exifremover:failedprocessgd', @@ -242,11 +270,13 @@ class exifremover_service extends service implements file_redactor_service_inter /** * Recreate the image using PHP GD library to strip all EXIF data. * - * @param string $content The source file content + * @param string $content The source file content. + * @param int $orientation The orientation value. The default is 1, which means no rotation. * @return null|string The path to the recreated image, or null on failure. */ private function recreate_image_gd( string $content, + int $orientation = self::TOP_LEFT, ): ?string { // Fetch the image information for this image. $imageinfo = @getimagesizefromstring($content); @@ -256,6 +286,8 @@ class exifremover_service extends service implements file_redactor_service_inter // Create a new image from the file. $image = @imagecreatefromstring($content); + $this->flip_gd($image, $orientation); + $destinationfile = make_request_directory() . '/output'; // Capture the image as a string object, rather than straight to file. @@ -274,6 +306,44 @@ class exifremover_service extends service implements file_redactor_service_inter return null; } + /** + * Flips the given GD image resource based on the specified orientation. + * + * @param \GDImage $image The GD image resource to be flipped. + * @param int $orientation The orientation value indicating how the image should be flipped. + * + * @return void + */ + private function flip_gd(\GDImage &$image, int $orientation): void { + switch ($orientation) { + case self::TOP_LEFT: + break; + case self::TOP_RIGHT: + imageflip($image, IMG_FLIP_HORIZONTAL); + break; + case self::BOTTOM_RIGHT: + $image = imagerotate($image, 180, 0); + break; + case self::BOTTOM_LEFT: + imageflip($image, IMG_FLIP_VERTICAL); + break; + case self::LEFT_TOP: + $image = imagerotate($image, -90, 0); + imageflip($image, IMG_FLIP_HORIZONTAL); + break; + case self::RIGHT_TOP: + $image = imagerotate($image, -90, 0); + break; + case self::RIGHT_BOTTOM: + $image = imagerotate($image, 90, 0); + imageflip($image, IMG_FLIP_HORIZONTAL); + break; + case self::LEFT_BOTTOM: + $image = imagerotate($image, 90, 0); + break; + } + } + /** * Returns true if the service is enabled, and false if it is not. * diff --git a/files/tests/fixtures/redactor/righttop.jpg b/files/tests/fixtures/redactor/righttop.jpg new file mode 100644 index 00000000000..e5d224249b9 Binary files /dev/null and b/files/tests/fixtures/redactor/righttop.jpg differ diff --git a/files/tests/fixtures/redactor/topleft.jpg b/files/tests/fixtures/redactor/topleft.jpg new file mode 100644 index 00000000000..1aa26b4358b Binary files /dev/null and b/files/tests/fixtures/redactor/topleft.jpg differ diff --git a/files/tests/redactor/services/exifremover_service_test.php b/files/tests/redactor/services/exifremover_service_test.php index 36d037b77a6..793cb4e7175 100644 --- a/files/tests/redactor/services/exifremover_service_test.php +++ b/files/tests/redactor/services/exifremover_service_test.php @@ -67,6 +67,87 @@ final class exifremover_service_test extends \advanced_testcase { $this->assertStringNotContainsString('Orientation', $newexif); } + /** + * Tests the `exifremover_service` functionality to flip orientation. + * + * @dataProvider exifremover_service_flip_orientation_provider + * @param string $sourcepath the path to the source image. + * @param string $expectedpath the path to the expected image. + * @param bool $expectedresult the expected result of the comparison. + */ + public function test_exifremover_service_flip_orientation_with_gd( + string $sourcepath, + string $expectedpath, + bool $expectedresult + ): void { + $this->resetAfterTest(true); + + // Ensure that the exif remover tool path is not set. + set_config('exifremovertoolpath', null, 'core_files'); + + // Flip the orientation. + $service = new exifremover_service(); + $newfile = $service->redact_file_by_path('image/jpeg', $sourcepath); + + // Compare the actual and expected images. + $this->assertEquals($expectedresult, $this->compare_images($newfile, $expectedpath)); + } + + /** + * Data provider for test_exifremover_service_flip_orientation(). + * + * @return array + */ + public static function exifremover_service_flip_orientation_provider(): array { + return [ + 'Flip right-top' => [ + 'sourcepath' => self::get_fixture_path('core_files', 'redactor/righttop.jpg'), + 'expectedpath' => self::get_fixture_path('core_files', 'redactor/topleft.jpg'), + 'expectedresult' => true, + ], + 'The image will not be the same after the flip process' => [ + 'sourcepath' => self::get_fixture_path('core_files', 'redactor/righttop.jpg'), + 'expectedpath' => self::get_fixture_path('core_files', 'redactor/righttop.jpg'), + 'expectedresult' => false, + ], + ]; + } + + /** + * Compares two images pixel by pixel. + * + * @param string $image1path the path to the first image. + * @param string $image2path the path to the second image. + * @return bool True if the images are identical, false otherwise. + */ + private function compare_images(string $image1path, string $image2path): bool { + $image1 = imagecreatefromjpeg($image1path); + $image2 = imagecreatefromjpeg($image2path); + + if (!$image1 || !$image2) { + return false; + } + + $width1 = imagesx($image1); + $height1 = imagesy($image1); + $width2 = imagesx($image2); + $height2 = imagesy($image2); + + if ($width1 !== $width2 || $height1 !== $height2) { + return false; + } + + for ($x = 0; $x < $width1; $x++) { + for ($y = 0; $y < $height1; $y++) { + if (imagecolorat($image1, $x, $y) !== imagecolorat($image2, $x, $y)) { + return false; + } + } + } + + return true; + } + /** * Tests the `exifremover_service` functionality using ExifTool. * diff --git a/lang/en/files.php b/lang/en/files.php index 167e02860a1..2c1aa1ff0f1 100644 --- a/lang/en/files.php +++ b/lang/en/files.php @@ -32,8 +32,8 @@ $string['redactor'] = 'File redaction'; $string['redactor:exifremover'] = 'EXIF remover'; $string['redactor:exifremover:emptyremovetags'] = 'Remove tags can not be empty!'; $string['redactor:exifremover:enabled'] = 'Enable EXIF remover'; -$string['redactor:exifremover:enabled_desc'] = 'By default, EXIF Remover only supports JPG files using PHP GD, or ExifTool if it is configured. -This degrades the quality of the image and removes the orientation tag. +$string['redactor:exifremover:enabled_desc'] = 'By default, EXIF Remover only supports JPG files using PHP GD or ExifTool if configured. +Using PHP GD for this purpose can degrade the quality of the image. To enhance the performance of EXIF Remover, please configure the ExifTool settings below.