diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index a674b3d97..caf97d86a 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -128,6 +128,7 @@ New features ### Images * **Rounded images.** Images take CSS `border-radius`, including per-corner, elliptical and percentage radii. The picture, its background and its border all follow the curve. [#70] +* **`object-fit`, `object-position` and `aspect-ratio` on images, in standard mode.** An image given a width and a height kept being stretched to them. Now `object-fit: contain`, `cover`, `none` and `scale-down` scale the picture inside that box and cut it off at the box's content edge, or at its rounded corners, and `object-position` places it there with keywords, percentages and lengths, centred by default. `aspect-ratio` gives the length not set, so `width: 60mm; aspect-ratio: 1 / 1` is a 60 mm square; `auto` keeps the picture's own proportions. These were ignored before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to ignore them as before. [#604] * **Exif orientation.** Set `useImageExifOrientation` to turn JPEGs the way their Exif `Orientation` tag says, as browsers do. It is off by default. `imageJpegQuality` sets the quality of the re-encoded image. [#35] [#51] ### Forms @@ -965,3 +966,4 @@ These changes do not change output. [#691]: https://github.com/GravityPDF/mpdf/pull/691 [#584]: https://github.com/GravityPDF/mpdf/issues/584 [#689]: https://github.com/GravityPDF/mpdf/pull/689 +[#604]: https://github.com/GravityPDF/mpdf/issues/604 diff --git a/src/Image/ImageSizing.php b/src/Image/ImageSizing.php index 2aa0c0075..ff0c3b425 100644 --- a/src/Image/ImageSizing.php +++ b/src/Image/ImageSizing.php @@ -10,7 +10,8 @@ * A sizing is an array of the lengths an image was given, in millimetres: w and h (0 when not given), minw, maxw, minh * and maxh (false when not given), natural_w and natural_h (the size it takes when given neither w nor h), extrawidth * and extraheight (its padding, border and margin), fit_w and fit_h (the most room it may take up), and percent: the - * percentages of a table cell's width still to resolve, keyed as the length they stand for. + * percentages of a table cell's width still to resolve, keyed as the length they stand for. An image given an + * aspect-ratio also has ratio: the width and height of the proportions it is sized by, in place of the picture's own. */ class ImageSizing { @@ -27,6 +28,10 @@ class ImageSizing */ public static function fit(array $sizing, $ratioW, $ratioH, $basis = null) { + if (isset($sizing['ratio'])) { + list($ratioW, $ratioH) = $sizing['ratio']; + } + foreach ($sizing['percent'] as $key => $percent) { $sizing[$key] = $basis === null ? false : $percent / 100 * $basis; } diff --git a/src/Image/ObjectFit.php b/src/Image/ObjectFit.php new file mode 100644 index 000000000..9a760571d --- /dev/null +++ b/src/Image/ObjectFit.php @@ -0,0 +1,176 @@ + + */ + private static $keywords = ['left' => 0, 'top' => 0, 'center' => 50, 'right' => 100, 'bottom' => 100]; + + /** + * The size the image is drawn at in a box of the given size + * + * @param string $fit + * @param float $boxW + * @param float $boxH + * @param float $naturalW + * @param float $naturalH + * + * @return float[] Width and height + */ + public static function size($fit, $boxW, $boxH, $naturalW, $naturalH) + { + if ($naturalW <= 0 || $naturalH <= 0) { + return [$boxW, $boxH]; + } + + switch ($fit) { + case 'contain': + $scale = min($boxW / $naturalW, $boxH / $naturalH); + break; + case 'cover': + $scale = max($boxW / $naturalW, $boxH / $naturalH); + break; + case 'none': + $scale = 1; + break; + case 'scale-down': + $scale = min(1, $boxW / $naturalW, $boxH / $naturalH); + break; + default: + return [$boxW, $boxH]; + } + + return [$naturalW * $scale, $naturalH * $scale]; + } + + /** + * Where the image is drawn: fitted into its content box, keyed INNER-X, INNER-Y, INNER-WIDTH and INNER-HEIGHT, + * and placed by its object-position + * + * @param array $objattr The image, placed on the page + * @param float $k The factor a table has shrunk the image by + * + * @return float[] x, y, width and height + */ + public static function place(array $objattr, $k) + { + $boxW = $objattr['INNER-WIDTH']; + $boxH = $objattr['INNER-HEIGHT']; + $fit = $objattr['object_fit']; + list($w, $h) = self::size($fit['fit'], $boxW, $boxH, $fit['natural'][0] / $k, $fit['natural'][1] / $k); + + $position = $fit['position']; + $x = $objattr['INNER-X'] + $position[0] / 100 * ($boxW - $w) + $position[1] / $k; + $y = $objattr['INNER-Y'] + $position[2] / 100 * ($boxH - $h) + $position[3] / $k; + + return [$x, $y, $w, $h]; + } + + /** + * Reads object-position: one or two keywords, percentages or lengths, as background-position is read. A single + * value is the horizontal one, or the vertical one when it is top or bottom, and the other axis is centred. + * + * @param string $value + * @param \Mpdf\SizeConverter $sizeConverter + * @param float $fontSize What em is relative to + * + * @return float[]|null [x percent, x offset, y percent, y offset], or null when the value is not a position + */ + public static function position($value, SizeConverter $sizeConverter, $fontSize) + { + $bits = preg_split('/\s+/', strtolower(trim($value))); + if (count($bits) === 1) { + $bits = self::isVertical($bits[0]) ? ['center', $bits[0]] : [$bits[0], 'center']; + } + if (count($bits) !== 2) { + return null; + } + + // A vertical keyword first, or a horizontal one second, means the pair is written the other way round + if (self::isVertical($bits[0]) || self::isHorizontal($bits[1])) { + $bits = [$bits[1], $bits[0]]; + } + if (self::isVertical($bits[0]) || self::isHorizontal($bits[1])) { + return null; + } + + $x = self::axis($bits[0], $sizeConverter, $fontSize); + $y = self::axis($bits[1], $sizeConverter, $fontSize); + + return $x === null || $y === null ? null : array_merge($x, $y); + } + + /** + * One axis of object-position + * + * @param string $bit A keyword, a percentage or a length + * @param \Mpdf\SizeConverter $sizeConverter + * @param float $fontSize + * + * @return float[]|null The percentage of the room left in the box, and an offset in millimetres + */ + private static function axis($bit, SizeConverter $sizeConverter, $fontSize) + { + if (isset(self::$keywords[$bit])) { + return [self::$keywords[$bit], 0]; + } + if (substr($bit, -1) === '%' && is_numeric(substr($bit, 0, -1))) { + return [(float) $bit, 0]; + } + // A number with no unit is a length only when it is zero + if (!$sizeConverter->isLength($bit) || (is_numeric($bit) && (float) $bit != 0)) { + return null; + } + + return [0, $sizeConverter->convert($bit, 0, $fontSize, false)]; + } + + /** + * Whether a part of object-position can only be vertical + * + * @param string $bit + * + * @return bool + */ + private static function isVertical($bit) + { + return $bit === 'top' || $bit === 'bottom'; + } + + /** + * Whether a part of object-position can only be horizontal + * + * @param string $bit + * + * @return bool + */ + private static function isHorizontal($bit) + { + return $bit === 'left' || $bit === 'right'; + } + +} diff --git a/src/Mpdf.php b/src/Mpdf.php index b700f3353..3dcaaab4b 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -21,6 +21,7 @@ use Mpdf\Log\Context as LogContext; use Mpdf\Fonts\MetricsGenerator; use Mpdf\Image\ImageSizing; +use Mpdf\Image\ObjectFit; use Mpdf\Output\Destination; use Mpdf\Pdf\DocumentProfile; use Mpdf\Invoice\EmbeddedInvoiceInterface; @@ -8115,6 +8116,12 @@ function printobjectbuffer($is_table = false, $blockdir = false) $this->SetAlpha($objattr['opacity']); } + // The content box, relative to the picture, which object-fit may draw elsewhere + $contentBox = [0, 0, $objattr['INNER-WIDTH'], $objattr['INNER-HEIGHT']]; + if (isset($objattr['object_fit'])) { + list($objattr, $contentBox) = $this->fitImage($objattr, $k); + } + $obiw = $objattr['INNER-WIDTH']; $obih = $objattr['INNER-HEIGHT']; @@ -8261,10 +8268,15 @@ function printobjectbuffer($is_table = false, $blockdir = false) } /* -- END BACKGROUNDS -- */ /* -- BORDER-RADIUS -- */ + // Placed only now, as a list marker may have moved the picture + $contentBox[0] += $objattr['INNER-X']; + $contentBox[1] += $objattr['INNER-Y']; $clip = ''; if ($box) { // The picture is trimmed to the content edge - $clip = $this->roundedBox->path($this->h, $objattr['INNER-X'], $objattr['INNER-Y'], $objattr['INNER-X'] + $objattr['INNER-WIDTH'], $objattr['INNER-Y'] + $objattr['INNER-HEIGHT'], $box['content']) . 'W n '; + $clip = $this->roundedBox->path($this->h, $contentBox[0], $contentBox[1], $contentBox[0] + $contentBox[2], $contentBox[1] + $contentBox[3], $box['content']) . 'W n '; + } elseif (isset($objattr['object_fit'])) { + $clip = sprintf('%.3F %.3F %.3F %.3F re W n ', $contentBox[0] * Mpdf::SCALE, ($this->h - $contentBox[1]) * Mpdf::SCALE, $contentBox[2] * Mpdf::SCALE, -$contentBox[3] * Mpdf::SCALE); } /* -- END BORDER-RADIUS -- */ // The CSS transform goes outermost, about the box's centre as it does for the background and border; the @@ -8283,7 +8295,7 @@ function printobjectbuffer($is_table = false, $blockdir = false) $this->writer->write($outstring); // LINK if (isset($objattr['link'])) { - $this->Link($objattr['INNER-X'], $objattr['INNER-Y'], $objattr['INNER-WIDTH'], $objattr['INNER-HEIGHT'], $objattr['link']); + $this->Link($contentBox[0], $contentBox[1], $contentBox[2], $contentBox[3], $objattr['link']); } if (isset($objattr['opacity'])) { $this->SetAlpha(1); @@ -19001,6 +19013,22 @@ function PaintDivLnBorder($state = 0, $blvl = 0, $h = 0) $this->y = $save_y; } + /** + * The image moved into the rectangle its object-fit and object-position draw it in, and its content box + * + * @param array $objattr The image, placed on the page + * @param float $k The factor a table has shrunk the image by + * + * @return array The image, and its content box as x and y from the picture's top left corner, width and height + */ + private function fitImage(array $objattr, $k) + { + $box = [$objattr['INNER-X'], $objattr['INNER-Y'], $objattr['INNER-WIDTH'], $objattr['INNER-HEIGHT']]; + list($objattr['INNER-X'], $objattr['INNER-Y'], $objattr['INNER-WIDTH'], $objattr['INNER-HEIGHT']) = ObjectFit::place($objattr, $k); + + return [$objattr, [$box[0] - $objattr['INNER-X'], $box[1] - $objattr['INNER-Y'], $box[2], $box[3]]]; + } + function PaintImgBorder($objattr, $is_table, $box = null) { // Borders are disabled in columns - messes up the repositioning in printcolumnbuffer diff --git a/src/Tag/Img.php b/src/Tag/Img.php index 40375a397..1ff07fe9f 100644 --- a/src/Tag/Img.php +++ b/src/Tag/Img.php @@ -3,7 +3,10 @@ namespace Mpdf\Tag; use Mpdf\Css\BorderRadius; +use Mpdf\Css\NormalizeProperties; +use Mpdf\CssMode; use Mpdf\Image\ImageSizing; +use Mpdf\Image\ObjectFit; use Mpdf\Mpdf; use Mpdf\Utils\NumericString; @@ -274,6 +277,10 @@ public function open($attr, &$ahtml, &$ihtml) 'fit_h' => $maxHeight, 'percent' => $percent, ]; + if ($this->mpdf->cssMode === CssMode::STANDARD) { + $sizing = $this->withAspectRatio($sizing, $properties); + $objattr = $this->withObjectFit($objattr, $properties, $naturalW, $naturalH); + } list($w, $h) = ImageSizing::fit($sizing, $info['w'], $info['h']); $objattr['type'] = 'image'; $objattr['itype'] = $info['type']; @@ -416,6 +423,72 @@ private function percentage($value) return NumericString::containsPercentChar($value) ? (float) $value : null; } + /** + * The sizing with the proportions aspect-ratio gives the image in place of its own, which an image given neither + * a width nor a height takes at its natural width. With auto the image keeps its own proportions. + * + * @param array $sizing + * @param array $properties + * + * @return array + */ + private function withAspectRatio(array $sizing, array $properties) + { + if (!isset($properties['ASPECT-RATIO'])) { + return $sizing; + } + + $number = NormalizeProperties::NUMBER; + if (!preg_match('/^' . $number . '\s*(?:\/\s*' . $number . ')?$/', trim($properties['ASPECT-RATIO']), $m)) { + return $sizing; + } + + $ratioW = (float) $m[1]; + $ratioH = isset($m[2]) ? (float) $m[2] : 1.0; + // A ratio with a zero in it is degenerate, which CSS reads as auto + if ($ratioW <= 0 || $ratioH <= 0) { + return $sizing; + } + + $sizing['ratio'] = [$ratioW, $ratioH]; + $sizing['natural_h'] = $sizing['natural_w'] * $ratioH / $ratioW; + + return $sizing; + } + + /** + * The image with the object-fit and object-position it is drawn by inside its box, and its natural size to fit + * + * @param array $objattr + * @param array $properties + * @param float $naturalW + * @param float $naturalH + * + * @return array + */ + private function withObjectFit(array $objattr, array $properties, $naturalW, $naturalH) + { + $fit = isset($properties['OBJECT-FIT']) ? strtolower(trim($properties['OBJECT-FIT'])) : 'fill'; + if (!in_array($fit, ObjectFit::FITS, true)) { + $fit = 'fill'; + } + + $position = null; + if (isset($properties['OBJECT-POSITION'])) { + $position = ObjectFit::position($properties['OBJECT-POSITION'], $this->sizeConverter, $this->mpdf->FontSize); + } + if ($position === null) { + $position = ObjectFit::CENTRE; + } + + // fill at the centre is how an image is drawn without either property + if ($fit !== 'fill' || $position != ObjectFit::CENTRE) { + $objattr['object_fit'] = ['fit' => $fit, 'position' => $position, 'natural' => [$naturalW, $naturalH]]; + } + + return $objattr; + } + public function close(&$ahtml, &$ihtml) { } diff --git a/tests/Mpdf/ObjectFitTest.php b/tests/Mpdf/ObjectFitTest.php new file mode 100644 index 000000000..6af9f5f65 --- /dev/null +++ b/tests/Mpdf/ObjectFitTest.php @@ -0,0 +1,347 @@ +'); + $page = $this->pages($this->render($html, $config))[0]; + + $this->assertSame(1, preg_match('/q (.*?)([-\d.]+) 0 0 ([-\d.]+) ([-\d.]+) ([-\d.]+) cm \/I\d+ Do Q/', $page, $m)); + + $box = null; + if (preg_match('/^([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) re W n $/', $m[1], $clip)) { + $box = ['x' => $clip[1] / Mpdf::SCALE, 'top' => $clip[2] / Mpdf::SCALE, 'w' => $clip[3] / Mpdf::SCALE, 'h' => -$clip[4] / Mpdf::SCALE]; + } + + $image = [ + 'x' => $m[4] / Mpdf::SCALE, + 'top' => ($m[5] + $m[3]) / Mpdf::SCALE, + 'w' => $m[2] / Mpdf::SCALE, + 'h' => $m[3] / Mpdf::SCALE, + ]; + + return ['box' => $box, 'image' => $image, 'clip' => $m[1]]; + } + + /** + * Asserts the picture is drawn at the given size and offset from the top left of its box + * + * @param array $drawn As drawn() gives it + * @param float[] $size Width and height + * @param float[] $offset Right and down from the box's top left corner + */ + private function assertDrawnAt(array $drawn, array $size, array $offset) + { + $this->assertNotNull($drawn['box'], 'clipped to its box'); + $this->assertEqualsWithDelta($size[0], $drawn['image']['w'], 0.01, 'width'); + $this->assertEqualsWithDelta($size[1], $drawn['image']['h'], 0.01, 'height'); + $this->assertEqualsWithDelta($offset[0], $drawn['image']['x'] - $drawn['box']['x'], 0.01, 'left'); + $this->assertEqualsWithDelta($offset[1], $drawn['box']['top'] - $drawn['image']['top'], 0.01, 'top'); + } + + /** + * cover fills the box with the picture scaled up, and object-position: left top keeps its top left corner + */ + public function testCoverFillsTheBoxAndCropsTheRest() + { + $drawn = $this->drawn('width: 60mm; height: 20mm; object-fit: cover; object-position: left top'); + + $this->assertEqualsWithDelta(60, $drawn['box']['w'], 0.01); + $this->assertEqualsWithDelta(20, $drawn['box']['h'], 0.01); + $this->assertDrawnAt($drawn, [60, 69.6], [0, 0]); + } + + /** + * contain fits the whole picture in the box, centred by default + */ + public function testContainFitsThePictureInsideTheBoxCentred() + { + $w = 20 * 100 / 116; + + $this->assertDrawnAt($this->drawn('width: 60mm; height: 20mm; object-fit: contain'), [$w, 20], [(60 - $w) / 2, 0]); + } + + /** + * none keeps the picture's natural size, centred and cropped by the box + */ + public function testNoneKeepsTheNaturalSize() + { + $w = 100 * 25.4 / 96; + $h = 116 * 25.4 / 96; + + $this->assertDrawnAt($this->drawn('width: 20mm; height: 20mm; object-fit: none'), [$w, $h], [(20 - $w) / 2, (20 - $h) / 2]); + } + + /** + * scale-down is contain in a box smaller than the picture, and none in a larger one + */ + public function testScaleDownIsTheSmallerOfNoneAndContain() + { + $w = 100 * 25.4 / 96; + $h = 116 * 25.4 / 96; + + $this->assertDrawnAt($this->drawn('width: 60mm; height: 60mm; object-fit: scale-down'), [$w, $h], [(60 - $w) / 2, (60 - $h) / 2]); + $this->assertDrawnAt($this->drawn('width: 60mm; height: 20mm; object-fit: scale-down'), [20 * 100 / 116, 20], [(60 - 20 * 100 / 116) / 2, 0]); + } + + /** + * contain and cover on a wide and a tall picture in a square box + * + * @return array + */ + public function wideAndTallProvider() + { + return [ + 'wide, contain' => [self::WIDE, 'contain', [40, 22.5], [0, 8.75]], + 'wide, cover' => [self::WIDE, 'cover', [40 * 16 / 9, 40], [-(40 * 16 / 9 - 40) / 2, 0]], + 'tall, contain' => [self::TALL, 'contain', [22.5, 40], [8.75, 0]], + 'tall, cover' => [self::TALL, 'cover', [40, 40 * 16 / 9], [0, -(40 * 16 / 9 - 40) / 2]], + ]; + } + + /** + * Each picture is scaled about the long or the short side, and centred + * + * @dataProvider wideAndTallProvider + * + * @param string $image + * @param string $fit + * @param float[] $size + * @param float[] $offset + */ + public function testWideAndTallPictures($image, $fit, array $size, array $offset) + { + $this->assertDrawnAt($this->drawn('width: 40mm; height: 40mm; object-fit: ' . $fit, $image), $size, $offset); + } + + /** + * object-position values, each with where a 22.5 x 40 mm picture sits in a 40 mm square + * + * @return array + */ + public function positionProvider() + { + return [ + 'keywords' => ['right bottom', [17.5, 0]], + 'vertical keyword first' => ['top right', [17.5, 0]], + 'one keyword' => ['left', [0, 0]], + 'one vertical keyword' => ['bottom', [8.75, 0]], + 'percentages' => ['100% 0%', [17.5, 0]], + 'one percentage' => ['25%', [17.5 / 4, 0]], + 'lengths' => ['5mm 2mm', [5, 2]], + 'a length and a keyword' => ['2mm center', [2, 0]], + 'unitless zero' => ['0 0', [0, 0]], + ]; + } + + /** + * object-position is read as background-position is: keywords, percentages of the room left and lengths + * + * @dataProvider positionProvider + * + * @param string $position + * @param float[] $offset + */ + public function testObjectPositionPlacesThePicture($position, array $offset) + { + $drawn = $this->drawn('width: 40mm; height: 40mm; object-fit: contain; object-position: ' . $position, self::TALL); + + $this->assertDrawnAt($drawn, [22.5, 40], $offset); + } + + /** + * Values that are not positions: two horizontal keywords, an unknown unit, a number with no unit, three parts + * + * @return array + */ + public function invalidPositionProvider() + { + return [['left right'], ['10foo 0'], ['5 0'], ['left top 2mm']]; + } + + /** + * A position that is not one is ignored, and the picture stays centred + * + * @dataProvider invalidPositionProvider + * + * @param string $position + */ + public function testAnInvalidPositionLeavesThePictureCentred($position) + { + $drawn = $this->drawn('width: 40mm; height: 40mm; object-fit: contain; object-position: ' . $position, self::TALL); + + $this->assertDrawnAt($drawn, [22.5, 40], [8.75, 0]); + } + + /** + * With fill, the default, a length in object-position still moves the picture within its box, which crops it + */ + public function testALengthMovesAFilledPicture() + { + $this->assertDrawnAt($this->drawn('width: 40mm; height: 40mm; object-position: 5mm 0'), [40, 40], [5, 0]); + } + + /** + * fill, and no object-fit at all, stretch the picture to the box as before, with no clip + */ + public function testFillStretchesThePictureUnclipped() + { + foreach (['object-fit: fill', ''] as $fit) { + $drawn = $this->drawn('width: 60mm; height: 20mm; ' . $fit); + + $this->assertNull($drawn['box']); + $this->assertEqualsWithDelta(60, $drawn['image']['w'], 0.01); + $this->assertEqualsWithDelta(20, $drawn['image']['h'], 0.01); + } + } + + /** + * The legacy CSS mode reads none of the three properties + */ + public function testTheLegacyModeIgnoresThem() + { + $drawn = $this->drawn('width: 60mm; height: 20mm; object-fit: cover; object-position: left top', self::PORTRAIT, ['cssMode' => CssMode::LEGACY]); + $this->assertNull($drawn['box']); + $this->assertEqualsWithDelta(60, $drawn['image']['w'], 0.01); + $this->assertEqualsWithDelta(20, $drawn['image']['h'], 0.01); + + $drawn = $this->drawn('width: 60mm; aspect-ratio: 1 / 1', self::PORTRAIT, ['cssMode' => CssMode::LEGACY]); + $this->assertEqualsWithDelta(69.6, $drawn['image']['h'], 0.01); + } + + /** + * aspect-ratio values, each with the size of the image it gives + * + * @return array + */ + public function aspectRatioProvider() + { + $naturalW = 100 * 25.4 / 96; + + return [ + 'a width' => ['width: 60mm; aspect-ratio: 1/1', [60, 60]], + 'a height' => ['height: 30mm; aspect-ratio: 2 / 1', [60, 30]], + 'neither, at the natural width' => ['aspect-ratio: 2', [$naturalW, $naturalW / 2]], + 'auto and a ratio keeps the picture\'s own' => ['width: 60mm; aspect-ratio: auto 3/2', [60, 69.6]], + 'auto' => ['width: 60mm; aspect-ratio: auto', [60, 69.6]], + 'a degenerate ratio is auto' => ['width: 60mm; aspect-ratio: 0 / 1', [60, 69.6]], + 'max-height follows the ratio' => ['width: 60mm; max-height: 20mm; aspect-ratio: 2/1', [40, 20]], + ]; + } + + /** + * aspect-ratio derives the length not given from the ratio, not from the picture, which is stretched to it + * + * @dataProvider aspectRatioProvider + * + * @param string $style + * @param float[] $size + */ + public function testAspectRatioSizesTheBox($style, array $size) + { + $drawn = $this->drawn($style); + + $this->assertEqualsWithDelta($size[0], $drawn['image']['w'], 0.01, 'width'); + $this->assertEqualsWithDelta($size[1], $drawn['image']['h'], 0.01, 'height'); + } + + /** + * aspect-ratio sets the box that object-fit then fits the picture into + */ + public function testAspectRatioAndCoverTogether() + { + $drawn = $this->drawn('width: 60mm; aspect-ratio: 3 / 1; object-fit: cover; object-position: left top'); + + $this->assertEqualsWithDelta(20, $drawn['box']['h'], 0.01); + $this->assertDrawnAt($drawn, [60, 69.6], [0, 0]); + } + + /** + * A rounded image clips the fitted picture to its curved content edge, and its border goes round the box + */ + public function testARadiusClipsTheFittedPicture() + { + $drawn = $this->drawn('width: 60mm; height: 20mm; object-fit: cover; border: 1mm solid red; border-radius: 5mm'); + + $this->assertSame(16, substr_count($drawn['clip'], ' c '), 'four corners of the curve'); + $this->assertStringNotContainsString(' re W n', $drawn['clip']); + $this->assertEqualsWithDelta(60, $drawn['image']['w'], 0.01); + $this->assertEqualsWithDelta(69.6, $drawn['image']['h'], 0.01); + } + + /** + * Padding and a border go round the box, not the fitted picture + */ + public function testPaddingAndBorderGoRoundTheBox() + { + $drawn = $this->drawn('width: 60mm; height: 20mm; padding: 3mm; border: 1mm solid red; object-fit: contain'); + + $this->assertEqualsWithDelta(60, $drawn['box']['w'], 0.01); + $this->assertEqualsWithDelta(20, $drawn['box']['h'], 0.01); + $this->assertEqualsWithDelta(15 + 4, $drawn['box']['x'], 0.01, 'inside the left margin, border and padding'); + $this->assertDrawnAt($drawn, [20 * 100 / 116, 20], [(60 - 20 * 100 / 116) / 2, 0]); + } + + /** + * A linked image is clickable over its whole box, not only where the fitted picture is + */ + public function testALinkCoversTheBox() + { + $html = ''; + $annotations = $this->annotations($this->render($html))[0]; + + $this->assertSame(1, preg_match('/\/Rect \[([-\d.]+) [-\d.]+ ([-\d.]+) [-\d.]+\]/', $annotations, $rect)); + $this->assertEqualsWithDelta(60, ($rect[2] - $rect[1]) / Mpdf::SCALE, 0.01); + } + + /** + * In a table cell, a table shrunk to fit the page shrinks the fitted picture, its box and its position alike + */ + public function testAnImageInAShrunkTableCell() + { + $wrap = '
%sx
'; + $drawn = $this->drawn('width: 80mm; height: 40mm; object-fit: contain; object-position: 10mm 0', self::TALL, [], $wrap); + + $k = 80 / $drawn['box']['w']; + $this->assertGreaterThan(1, $k, 'the table is shrunk'); + $this->assertEqualsWithDelta(40 / $k, $drawn['box']['h'], 0.01); + $this->assertDrawnAt($drawn, [22.5 / $k, 40 / $k], [10 / $k, 0]); + } + +} diff --git a/tests/Snapshots/ObjectFitSnapshotTest.php b/tests/Snapshots/ObjectFitSnapshotTest.php new file mode 100644 index 000000000..f7f30c6ef --- /dev/null +++ b/tests/Snapshots/ObjectFitSnapshotTest.php @@ -0,0 +1,98 @@ + [ + [$photo, $box . 'object-fit: fill', 'fill: the photo stretched to the box.'], + [$photo, $box . 'object-fit: contain', 'contain: the whole photo, centred, grey bars above and below it.'], + [$photo, $box . 'object-fit: cover', 'cover: the box filled, the photo cut off at both sides.'], + [$wide, $box . 'object-fit: none', 'none: the middle of the 16:9 picture at its natural size.'], + [$wide, 'width: 100mm; height: 60mm; object-fit: scale-down', 'scale-down in a 100 x 60 mm box: the 16:9 picture at its natural size, centred.'], + [$tall, $box . 'object-fit: contain', 'contain on a tall picture: grey bars at the sides.'], + [$wide, $box . 'object-fit: cover', 'cover on a wide picture: cut off at the sides.'], + ], + 'object-position' => [ + [$tall, $box . 'object-fit: contain; object-position: left top', 'left top: the picture against the left of the box.'], + [$tall, $box . 'object-fit: contain; object-position: right', 'right: the picture against the right of the box.'], + [$tall, $box . 'object-fit: contain; object-position: 25% 0', '25% 0: a quarter of the grey on the left.'], + [$tall, $box . 'object-fit: contain; object-position: 5mm 0', '5mm 0: 5 mm of grey on the left.'], + [$photo, $box . 'object-fit: cover; object-position: left center', 'cover, left center: the left end of the photo kept, its right end cut off.'], + ], + 'aspect-ratio' => [ + [$photo, 'width: 40mm; aspect-ratio: 1 / 1', 'width 40mm, 1 / 1: a 40 mm square, the photo stretched to it.'], + [$photo, 'height: 20mm; aspect-ratio: 3 / 1', 'height 20mm, 3 / 1: 60 x 20 mm, the photo stretched to it.'], + [$photo, 'width: 60mm; aspect-ratio: 3 / 1; object-fit: cover', 'width 60mm, 3 / 1, cover: 60 x 20 mm, the photo cut off a little at both sides.'], + [$photo, 'width: 40mm; aspect-ratio: auto 1 / 1', 'auto 1 / 1: the photo in its own proportions.'], + ], + 'Rounded, padded, in a table, and SVG' => [ + [$photo, $box . 'object-fit: cover; border-radius: 6mm', 'cover with border-radius: the photo filling the rounded box.'], + [$tall, $box . 'object-fit: contain; padding: 2mm; border: 1mm solid #c00', 'contain with padding and a red border: the border round the 40 x 25 mm box, not the picture.'], + [$tall, $box . 'object-fit: contain; object-position: right', 'in a table cell, contain, right: the picture against the right.', '
%s
'], + ['demo.svg', $box . 'object-fit: contain', 'an SVG with contain: the whole drawing, in its own proportions.'], + ], + ]; + } + + /** + * Each image under a caption saying what it should show + */ + public function generatePdf() + { + $html = ' +

object-fit, object-position and aspect-ratio

'; + + $dir = __DIR__ . '/../data/img/'; + foreach ($this->cases() as $heading => $images) { + $html .= '

' . $heading . '

'; + foreach ($images as $case) { + $img = ''; + $html .= '

' . $case[2] . '

' . (isset($case[3]) ? sprintf($case[3], $img) : '
' . $img . '
'); + } + } + + $this->mpdf = $this->createMpdf(['cssMode' => CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/object-fit.pdf b/tests/data/snapshots/object-fit.pdf new file mode 100644 index 000000000..b54348dc0 Binary files /dev/null and b/tests/data/snapshots/object-fit.pdf differ