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 = '
| %s | x |
| %s |
' . $case[2] . '
' . (isset($case[3]) ? sprintf($case[3], $img) : '