diff --git a/src/Config/ConfigVariables.php b/src/Config/ConfigVariables.php
index 0113ee996..b7d2ce514 100644
--- a/src/Config/ConfigVariables.php
+++ b/src/Config/ConfigVariables.php
@@ -295,6 +295,14 @@ public function __construct()
// Default dpi to output images if size not defined
// See also above "dpi"
'img_dpi' => 96,
+ // Rotate and mirror JPEGs to match their Exif Orientation tag, the way browsers do
+ // Off by default: it costs a GD re-encode of each image that carries an orientation to correct,
+ // and GD writes RGB, so a greyscale JPEG that needed correcting is embedded with three channels
+ 'useImageExifOrientation' => false,
+ // Quality GD writes a JPEG at when it has to re-encode one: correcting an Exif orientation,
+ // or converting a WebP or AVIF. 75 is GD's own default, so raising it grows every existing
+ // document that carries one of those; 85 is the knee if you would rather have the quality
+ 'imageJpegQuality' => 75,
// Specify whitelisted PHP streams to be used for images
// Useful to add custom streams like `s3`
// Note: for security reasons the `phar` stream cannot be used @see https://github.com/mpdf/mpdf/issues/949
diff --git a/src/Image/ImageProcessor.php b/src/Image/ImageProcessor.php
index 6c92f265d..c27d12b88 100644
--- a/src/Image/ImageProcessor.php
+++ b/src/Image/ImageProcessor.php
@@ -527,6 +527,16 @@ private function jpgDensity($data)
return 0;
}
+ /**
+ * The quality to hand imagejpeg(), clamped to the -1 to 100 PHP 8 insists on with an exception @ does not catch
+ *
+ * @return int
+ */
+ private function jpegQuality()
+ {
+ return max(-1, min(100, (int) $this->mpdf->imageJpegQuality));
+ }
+
/**
* Decode an image with GD, unless its header says the result would not fit memory_limit
*
@@ -633,6 +643,239 @@ private function jpgSegments($data)
}
}
+ /**
+ * Read the Orientation tag out of a JPEG's Exif APP1 segment
+ *
+ * The segment is CIPA DC-008 (Exif 2.32) 4.7.2: the APP1 marker and size, the "Exif\0\0" identifier,
+ * then a TIFF header every offset inside is relative to.
+ *
+ * @param string $data
+ *
+ * @return int 1 to 8, 1 being the identity that images without an orientation are shown at
+ */
+ private function jpgExifOrientation($data)
+ {
+ foreach ($this->jpgSegments($data) as $segment) {
+
+ // A segment shorter than its own header cannot hold a TIFF structure, whatever it starts with
+ if ($segment['marker'] !== 0xE1 || $segment['size'] < 8 || substr($data, $segment['payload'], 6) !== "Exif\0\0") { // APP1
+ continue;
+ }
+
+ return $this->exifOrientationFromTiff(substr($data, $segment['payload'] + 6, $segment['size'] - 8));
+ }
+
+ return 1;
+ }
+
+ /**
+ * Read the Orientation tag out of the TIFF structure an Exif segment wraps
+ *
+ * Orientation is tag 274 (0x0112) of type SHORT, CIPA DC-008 4.6.4 Table 4. The IFD around it is
+ * TIFF Rev. 6.0: a byte order mark, the number 42, an offset to IFD0, then a count of 12-byte entries.
+ *
+ * @param string $tiff Starting at the byte order mark, which every offset inside is relative to
+ *
+ * @return int
+ */
+ private function exifOrientationFromTiff($tiff)
+ {
+ $length = strlen($tiff);
+
+ if ($length < 8) {
+ return 1;
+ }
+
+ $byteOrder = substr($tiff, 0, 2);
+
+ if ($byteOrder !== 'II' && $byteOrder !== 'MM') {
+ return 1;
+ }
+
+ $bigEndian = $byteOrder === 'MM';
+
+ if ($this->tiffValue(substr($tiff, 2, 2), $bigEndian) !== 42) {
+ return 1;
+ }
+
+ $ifd = $this->tiffValue(substr($tiff, 4, 4), $bigEndian);
+
+ if ($ifd < 8 || $ifd + 2 > $length) {
+ return 1;
+ }
+
+ $entries = $this->tiffValue(substr($tiff, $ifd, 2), $bigEndian);
+
+ for ($i = 0; $i < $entries; $i++) {
+
+ $entry = $ifd + 2 + ($i * 12);
+
+ if ($entry + 12 > $length) {
+ break;
+ }
+
+ if ($this->tiffValue(substr($tiff, $entry, 2), $bigEndian) !== 0x0112) { // Orientation
+ continue;
+ }
+
+ if ($this->tiffValue(substr($tiff, $entry + 2, 2), $bigEndian) !== 3) { // SHORT, TIFF Rev. 6.0 type 3
+ break;
+ }
+
+ // A SHORT is left-justified in the four bytes the entry gives its value, either byte order
+ $orientation = $this->tiffValue(substr($tiff, $entry + 8, 2), $bigEndian);
+
+ return $orientation >= 1 && $orientation <= 8 ? $orientation : 1;
+ }
+
+ return 1;
+ }
+
+ /**
+ * Read a 2- or 4-byte TIFF integer of either byte order
+ *
+ * The bounds checks in exifOrientationFromTiff() are what guarantee a whole 2 or 4 bytes to read
+ *
+ * @param string $bytes
+ * @param bool $bigEndian
+ *
+ * @return int
+ */
+ private function tiffValue($bytes, $bigEndian)
+ {
+ if (!$bigEndian) {
+ $bytes = strrev($bytes);
+ }
+
+ return strlen($bytes) === 4 ? $this->fourBytesToInt($bytes) : $this->twoBytesToInt($bytes);
+ }
+
+ /**
+ * Re-encode a JPEG so its samples sit the way its Exif Orientation tag says they should be shown
+ *
+ * Orientation records where row 0 and column 0 of the stored samples belong on screen, which comes to
+ * a rotation, a mirror, or one of each. The eight cases are drawn in CIPA DC-008 Figure 11.
+ *
+ * GD writes a JFIF segment of its own in place of the one it read, so the density has to be handed back
+ * to it, or the re-encoded image would come out at GD's default of 96 whatever the original said.
+ *
+ * @param string $data
+ * @param int $orientation
+ * @param int $dpi 0 where the image has none
+ *
+ * @return string|null Null when the image is already the right way up, or GD cannot read or rewrite it,
+ * leaving the caller its original data
+ */
+ private function applyJpgExifOrientation($data, $orientation, $dpi)
+ {
+ // The rotation and mirror each orientation needs; one missing from the table is already the right way up
+ $transforms = [
+ 2 => [0, IMG_FLIP_HORIZONTAL],
+ 3 => [180, 0],
+ 4 => [0, IMG_FLIP_VERTICAL],
+ 5 => [270, IMG_FLIP_HORIZONTAL],
+ 6 => [270, 0],
+ 7 => [90, IMG_FLIP_HORIZONTAL],
+ 8 => [90, 0],
+ ];
+
+ if (!isset($transforms[$orientation])) {
+ return null;
+ }
+
+ list($rotation, $mirror) = $transforms[$orientation];
+
+ // A quarter turn is a second image the size of the first; a flip is done in place
+ $image = $this->imageFromString($data, $rotation === 90 || $rotation === 270 ? 2 : 1);
+
+ if (!$image) {
+ return null;
+ }
+
+ if ($rotation === 180) {
+
+ // A half turn is both flips, which imageflip() does in place where imagerotate() would copy the image
+ if (!@imageflip($image, IMG_FLIP_BOTH)) {
+ $this->destroyImage($image);
+ return null;
+ }
+
+ } elseif ($rotation) {
+
+ $rotated = @imagerotate($image, $rotation, 0);
+ $this->destroyImage($image);
+
+ if (!$rotated) {
+ return null;
+ }
+
+ $image = $rotated;
+ }
+
+ if ($mirror && !@imageflip($image, $mirror)) {
+ $this->destroyImage($image);
+ return null;
+ }
+
+ if ($dpi > 0 && function_exists('imageresolution')) { // PHP 7.2
+ @imageresolution($image, $dpi, $dpi);
+ }
+
+ ob_start();
+
+ try {
+ $written = @imagejpeg($image, null, $this->jpegQuality());
+ } finally {
+ $rotatedData = ob_get_clean();
+ $this->destroyImage($image);
+ $image = null; // destroyImage() does nothing on PHP 8+, and the pixels are dead from here
+ }
+
+ if (!$written || !$rotatedData) {
+ return null;
+ }
+
+ return $this->copyJpgIccProfile($data, $rotatedData);
+ }
+
+ /**
+ * Carry an ICC profile over to a re-encoded JPEG
+ *
+ * GD keeps the samples but drops every application segment, and the samples are still in whatever
+ * space the profile describes, so the profile has to travel with them. The APP2 chunks the profile is
+ * split into are ICC Technical Note 10-21.
+ *
+ * @param string $source
+ * @param string $target
+ *
+ * @return string
+ */
+ private function copyJpgIccProfile($source, $target)
+ {
+ $profile = '';
+
+ foreach ($this->jpgSegments($source) as $segment) {
+ if ($segment['marker'] === 0xE2 && substr($source, $segment['payload'], 12) === "ICC_PROFILE\0") { // APP2
+ $profile .= substr($source, $segment['offset'], 2 + $segment['size']);
+ }
+ }
+
+ if ($profile === '') {
+ return $target;
+ }
+
+ // An APP2 segment is legal anywhere before the frame, but JFIF wants its own APP0 to come
+ // first, and that is what GD writes
+ $p = 2;
+
+ foreach ($this->jpgSegments($target) as $first) {
+ $p = $first['marker'] === 0xE0 ? $first['offset'] + 2 + $first['size'] : 2;
+ break;
+ }
+
+ return substr_replace($target, $profile, $p, 0);
+ }
+
/**
* Corrects 2-byte integer to 8-bit depth value
* If original image is bpc != 8, tRNS will be in this bpc
@@ -723,7 +966,30 @@ public function processJpg($data, $file, $firstTime, $interpolation)
}
$a = $this->jpgDataFromHeader($hdr);
- $ppUx = $this->jpgDensity($data);
+ $ppUx = $this->jpgDensity($data); // Read before any re-encode, which replaces the segment it lives in
+
+ // GD reads the samples but not the colour space, so a CMYK image would come back inverted. It also
+ // decodes everything else to RGB, so a greyscale image comes back with three channels
+ if ($this->mpdf->useImageExifOrientation && $a[2] !== 'DeviceCMYK') {
+
+ $orientation = $this->jpgExifOrientation($data);
+ $rotated = $this->applyJpgExifOrientation($data, $orientation, $ppUx);
+
+ if ($rotated !== null) {
+
+ $data = $rotated;
+ $hdr = $this->jpgHeaderFromString($data);
+
+ if (!$hdr) {
+ return $this->imageError($file, $firstTime, 'Error parsing JPG header after applying Exif orientation');
+ }
+
+ $a = $this->jpgDataFromHeader($hdr);
+
+ } elseif ($orientation !== 1) {
+ $this->logger->warning(sprintf('Exif orientation %d not applied, image embedded as stored (%s)', $orientation, $file), ['context' => LogContext::IMAGES]);
+ }
+ }
$channels = (int) $a[4];
@@ -1447,7 +1713,7 @@ private function jpgViaGd($data, $file, $firstTime, $writer, $format)
return $this->imageError($file, $firstTime, sprintf('Error creating temporary file "%s" when using GD library to parse %s image', $checkfile, $format));
}
- @imagejpeg($im, $tempfile);
+ @imagejpeg($im, $tempfile, $this->jpegQuality());
$data = file_get_contents($tempfile);
$this->destroyImage($im);
unlink($tempfile);
diff --git a/src/Mpdf.php b/src/Mpdf.php
index 4cd9b55e1..21bfa3fcf 100644
--- a/src/Mpdf.php
+++ b/src/Mpdf.php
@@ -190,6 +190,8 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface
var $allow_html_optional_endtags;
var $img_dpi;
+ var $useImageExifOrientation;
+ var $imageJpegQuality;
var $whitelistStreamWrappers;
var $defaultheaderfontsize;
diff --git a/tests/Mpdf/Image/ExifOrientationTest.php b/tests/Mpdf/Image/ExifOrientationTest.php
new file mode 100644
index 000000000..a24dd4af9
--- /dev/null
+++ b/tests/Mpdf/Image/ExifOrientationTest.php
@@ -0,0 +1,277 @@
+ true];
+
+ private $mpdf;
+
+ protected function tear_down()
+ {
+ parent::tear_down();
+
+ if ($this->mpdf) {
+ $this->mpdf->cleanup();
+ $this->mpdf = null;
+ }
+ }
+
+ public function orientationProvider()
+ {
+ return [[1], [2], [3], [4], [5], [6], [7], [8]];
+ }
+
+ /**
+ * Each fixture holds the same picture stored a different way up, tagged with the orientation that
+ * says so. Half are little-endian Exif and half big-endian, so the set proves both are read.
+ *
+ * @dataProvider orientationProvider
+ */
+ public function testEveryOrientationIsShownTheSameWayUp($orientation)
+ {
+ $image = $this->render($this->fixture('exif-orientation-' . $orientation . '.jpg'), self::$on);
+
+ $this->assertSame(40, $image['w'], 'Corrected width');
+ $this->assertSame(20, $image['h'], 'Corrected height');
+ $this->assertCorners(self::$canonical, $image['data']);
+ }
+
+ public function testAnImageWithNoExifAtAllIsPassedThroughUntouched()
+ {
+ $file = $this->fixture('exif-orientation-none.jpg');
+ $image = $this->render($file, self::$on);
+
+ $this->assertSame(file_get_contents($file), $image['data']);
+ }
+
+ public function testAnImageAlreadyTheRightWayUpIsNotReEncoded()
+ {
+ $file = $this->fixture('exif-orientation-1.jpg');
+ $image = $this->render($file, self::$on);
+
+ $this->assertSame(file_get_contents($file), $image['data']);
+ }
+
+ public function testAnImageIsUsedAsStoredUntilTheSettingIsTurnedOn()
+ {
+ $file = $this->fixture('exif-orientation-6.jpg');
+ $image = $this->render($file);
+
+ $this->assertSame(20, $image['w']);
+ $this->assertSame(40, $image['h']);
+ $this->assertSame(file_get_contents($file), $image['data']);
+ }
+
+ public function testAColourProfileSurvivesTheRotation()
+ {
+ $image = $this->render($this->fixture('exif-orientation-6-icc.jpg'), self::$on);
+
+ $this->assertSame(40, $image['w']);
+ $this->assertNotFalse($image['icc']);
+ $this->assertSame('acsp', substr($image['icc'], 36, 4));
+ $this->assertSame(132, strlen($image['icc']));
+ }
+
+ public function testTheReEncodeHonoursTheConfiguredJpegQuality()
+ {
+ $file = $this->fixture('exif-orientation-6.jpg');
+
+ $low = $this->render($file, self::$on + ['imageJpegQuality' => 20]);
+ $high = $this->render($file, self::$on + ['imageJpegQuality' => 100]);
+
+ $this->assertLessThan(strlen($high['data']), strlen($low['data']));
+ }
+
+ public function reEncodePathProvider()
+ {
+ return [
+ 'a quarter turn, which GD does into a copy' => [6],
+ 'a half turn, which GD does in place' => [3],
+ ];
+ }
+
+ /**
+ * GD writes a JFIF segment of its own, at its default of 96 dpi, in place of the one it read
+ *
+ * @dataProvider reEncodePathProvider
+ */
+ public function testTheDensityOfTheOriginalSurvivesTheReEncode($orientation)
+ {
+ $data = $this->withJfifDensity(file_get_contents($this->fixture('exif-orientation-' . $orientation . '.jpg')), 300);
+ $image = $this->renderData($data, self::$on);
+
+ $this->assertSame(40, $image['w'], 'Corrected');
+ $this->assertSame(300, $image['set-dpi'], 'Density read off the original');
+
+ if (function_exists('imageresolution')) { // PHP 7.2
+ $this->assertSame(300, $this->jfifDensity($image['data']), 'Density written into the re-encoded segment');
+ }
+ }
+
+ /**
+ * GD decodes everything but CMYK to RGB and cannot write a one-component JPEG, so this is the one
+ * way a corrected image differs from its original beyond the correction: it grows to three channels
+ */
+ public function testAGreyscaleImageIsCorrectedAndComesBackWithThreeChannels()
+ {
+ $file = $this->fixture('exif-orientation-6-gray.jpg');
+
+ $asStored = $this->render($file);
+ $corrected = $this->render($file, self::$on);
+
+ $this->assertSame('DeviceGray', $asStored['cs']);
+ $this->assertSame(1, $asStored['ch']);
+
+ $this->assertSame(40, $corrected['w']);
+ $this->assertSame(20, $corrected['h']);
+ $this->assertSame('DeviceRGB', $corrected['cs']);
+ $this->assertSame(3, $corrected['ch']);
+ }
+
+ /**
+ * PHP 8 refuses a quality outside -1 to 100 with an exception, not a warning, where PHP 7 let libgd
+ * clamp it. Clamping first keeps the two the same, and keeps the output buffer the re-encode is
+ * written into from being left open
+ */
+ public function testAQualityOutsideGdsRangeIsClampedRatherThanRefused()
+ {
+ $file = $this->fixture('exif-orientation-6.jpg');
+ $level = ob_get_level();
+
+ $over = $this->render($file, self::$on + ['imageJpegQuality' => 150]);
+ $under = $this->render($file, self::$on + ['imageJpegQuality' => -5]);
+
+ $this->assertSame(40, $over['w']);
+ $this->assertSame(40, $under['w']);
+ $this->assertLessThan(strlen($over['data']), strlen($under['data']), '150 is written at 100, and -5 at GD\'s default');
+ $this->assertSame($level, ob_get_level());
+ }
+
+ /**
+ * The dimensions GD would allocate for come off the frame header, so a few bytes can claim an image
+ * that takes gigabytes to decode. One that would not fit is left the way it is stored
+ */
+ public function testAnImageTooBigForMemoryLimitIsLeftAsStored()
+ {
+ $data = $this->withFrameDimensions(file_get_contents($this->fixture('exif-orientation-6.jpg')), 60000, 60000);
+
+ $limit = ini_get('memory_limit');
+ ini_set('memory_limit', '1G'); // Well above what the suite uses, well below the 14 GB a copy of the image would take
+
+ try {
+ $image = $this->renderData($data, self::$on);
+ } finally {
+ ini_set('memory_limit', $limit);
+ }
+
+ $this->assertSame(60000, $image['w'], 'Read as stored');
+ $this->assertSame($data, $image['data']);
+ }
+
+ /**
+ * The samples in this fixture are stored portrait, and the orientation tag is what makes it landscape
+ */
+ public function testTheBoxTheImageIsDrawnInTurnsWithIt()
+ {
+ $corrected = $this->drawnBox(self::$on);
+ $asStored = $this->drawnBox();
+
+ $this->assertGreaterThan($corrected[1], $corrected[0], 'Drawn landscape once the orientation is read');
+ $this->assertGreaterThan($asStored[0], $asStored[1], 'Drawn portrait when it is not');
+ }
+
+ /**
+ * The width and height, in points, of the box the image is scaled into
+ */
+ private function drawnBox(array $config = [])
+ {
+ $this->mpdf = new Mpdf($config + ['mode' => 'c']);
+ $this->mpdf->compress = false;
+ $this->mpdf->WriteHTML('
');
+
+ $pdf = $this->mpdf->Output('', 'S');
+
+ $this->assertSame(1, preg_match('/q ([\d.]+) 0 0 ([\d.]+) [\d.]+ [\d.]+ cm \/I\d+ Do Q/', $pdf, $m));
+
+ return [(float) $m[1], (float) $m[2]];
+ }
+
+ private function fixture($name)
+ {
+ return __DIR__ . '/../../data/img/' . $name;
+ }
+
+ private function render($file, array $config = [])
+ {
+ $this->mpdf = new Mpdf($config + ['mode' => 'c']);
+ $this->mpdf->WriteHTML('');
+
+ return reset($this->mpdf->images);
+ }
+
+ private function renderData($data, array $config = [])
+ {
+ return $this->render('data:image/jpeg;base64,' . base64_encode($data), $config);
+ }
+
+ /**
+ * Put a JFIF segment (ITU-T T.871 6.3) giving this density ahead of any the image carries, which is
+ * where mPDF reads the first one it finds
+ */
+ private function withJfifDensity($data, $dpi)
+ {
+ $app0 = "JFIF\0" . "\x01\x01" . "\x01" . pack('n', $dpi) . pack('n', $dpi) . "\x00\x00"; // Version 1.1, units 1 (dpi), no thumbnail
+
+ return $this->afterSoi($data, "\xFF\xE0" . pack('n', strlen($app0) + 2) . $app0);
+ }
+
+ /**
+ * The density, in dots per inch, of the first JFIF segment in a JPEG
+ */
+ private function jfifDensity($data)
+ {
+ $this->assertSame(1, preg_match('/\xFF\xE0..JFIF\0..(.)(..)/s', $data, $m), 'Has a JFIF segment');
+ $this->assertSame(1, ord($m[1]), 'In dots per inch');
+
+ return unpack('n', $m[2])[1];
+ }
+
+ /**
+ * Read the four quadrants of a JPEG, clockwise from the top left
+ */
+ private function assertCorners(array $expected, $data)
+ {
+ $image = imagecreatefromstring($data);
+ $width = imagesx($image);
+ $height = imagesy($image);
+
+ $points = [[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]];
+
+ foreach ($points as $i => $point) {
+
+ $rgb = imagecolorsforindex($image, imagecolorat($image, (int) ($width * $point[0]), (int) ($height * $point[1])));
+ $actual = [$rgb['red'], $rgb['green'], $rgb['blue']];
+
+ foreach ($expected[$i] as $channel => $value) {
+ // JPEG is lossy, and 95 leaves ringing well inside a quadrant that is a solid colour
+ $this->assertEqualsWithDelta($value, $actual[$channel], 32, sprintf('Quadrant %d, channel %d, got %s', $i, $channel, implode(',', $actual)));
+ }
+ }
+ }
+
+}
diff --git a/tests/Snapshots/ExifOrientationSnapshotTest.php b/tests/Snapshots/ExifOrientationSnapshotTest.php
new file mode 100644
index 000000000..0f1feae09
--- /dev/null
+++ b/tests/Snapshots/ExifOrientationSnapshotTest.php
@@ -0,0 +1,84 @@
+mpdf and
+ * loading it with content
+ *
+ * @return void
+ * @internal Don't call any $this->mpdf->Output*() method
+ */
+ public function generatePdf()
+ {
+ $rows = [[1, 2], [3, 4], [5, 6], [7, 8]];
+
+ ob_start();
+ ?>
+
+
+
Every sample below holds the same picture, stored a different way up, with the Exif
+ Orientation tag that says which. With useImageExifOrientation on, all
+ eight have to be drawn the same way up as each other and as the browser draws them: twice as
+ wide as they are tall, red top left, green top right, blue bottom right, yellow bottom left.
The box each is drawn in has to turn with it. Every sample is given a width of 40mm and no + height, so the height comes off the corrected image, and a sample that was not corrected would + be drawn twice as tall as the others rather than half as tall.
+ +| + | ![]() |
+
+
Nothing to correct, so it is embedded exactly as it was stored.
+ +| none | +![]() |
+