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(); + ?> + + +

mPDF

+

Exif Orientation

+ +

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.

+ + + + + + + + + + +
+ +

An image with no Exif at all

+

Nothing to correct, so it is embedded exactly as it was stored.

+ + + + + + +
none
+ mpdf = new \Mpdf\Mpdf(['useImageExifOrientation' => true, 'imageJpegQuality' => 95]); + $this->mpdf->SetBasePath(__DIR__ . '/../data'); + + $this->mpdf->WriteHTML($html); + } +} diff --git a/tests/data/img/exif-orientation-1.jpg b/tests/data/img/exif-orientation-1.jpg new file mode 100644 index 000000000..52e660a09 Binary files /dev/null and b/tests/data/img/exif-orientation-1.jpg differ diff --git a/tests/data/img/exif-orientation-2.jpg b/tests/data/img/exif-orientation-2.jpg new file mode 100644 index 000000000..6546b7276 Binary files /dev/null and b/tests/data/img/exif-orientation-2.jpg differ diff --git a/tests/data/img/exif-orientation-3.jpg b/tests/data/img/exif-orientation-3.jpg new file mode 100644 index 000000000..9edfc6edd Binary files /dev/null and b/tests/data/img/exif-orientation-3.jpg differ diff --git a/tests/data/img/exif-orientation-4.jpg b/tests/data/img/exif-orientation-4.jpg new file mode 100644 index 000000000..c5a3e33e9 Binary files /dev/null and b/tests/data/img/exif-orientation-4.jpg differ diff --git a/tests/data/img/exif-orientation-5.jpg b/tests/data/img/exif-orientation-5.jpg new file mode 100644 index 000000000..baa2cc18f Binary files /dev/null and b/tests/data/img/exif-orientation-5.jpg differ diff --git a/tests/data/img/exif-orientation-6-gray.jpg b/tests/data/img/exif-orientation-6-gray.jpg new file mode 100644 index 000000000..2a6c20749 Binary files /dev/null and b/tests/data/img/exif-orientation-6-gray.jpg differ diff --git a/tests/data/img/exif-orientation-6-icc.jpg b/tests/data/img/exif-orientation-6-icc.jpg new file mode 100644 index 000000000..c47ac6809 Binary files /dev/null and b/tests/data/img/exif-orientation-6-icc.jpg differ diff --git a/tests/data/img/exif-orientation-6.jpg b/tests/data/img/exif-orientation-6.jpg new file mode 100644 index 000000000..9b80e5916 Binary files /dev/null and b/tests/data/img/exif-orientation-6.jpg differ diff --git a/tests/data/img/exif-orientation-7.jpg b/tests/data/img/exif-orientation-7.jpg new file mode 100644 index 000000000..a2fe3df65 Binary files /dev/null and b/tests/data/img/exif-orientation-7.jpg differ diff --git a/tests/data/img/exif-orientation-8.jpg b/tests/data/img/exif-orientation-8.jpg new file mode 100644 index 000000000..e2824c86d Binary files /dev/null and b/tests/data/img/exif-orientation-8.jpg differ diff --git a/tests/data/img/exif-orientation-none.jpg b/tests/data/img/exif-orientation-none.jpg new file mode 100644 index 000000000..33f0d18c9 Binary files /dev/null and b/tests/data/img/exif-orientation-none.jpg differ diff --git a/tests/data/snapshots/exif-orientation.pdf b/tests/data/snapshots/exif-orientation.pdf new file mode 100644 index 000000000..08217300f Binary files /dev/null and b/tests/data/snapshots/exif-orientation.pdf differ