Repository: ampproject/amp-toolbox-php (php)

Issue:
PHP 8.2 Compatibility
PHP 8.2 is expected to be released at the end of November 2022.

Figured it can't hurt to track PHP 8.2 compatibility in a dedicated ticket.

At first glance it looks like the dynamic properties deprecation creates some warnings.

```
Creation of dynamic property AmpProject\Dom\Document::$head is deprecated in /path/to/vendor/ampproject/amp-toolbox/src/Dom/Document/Filter/DeduplicateTag.php on line 65

Creation of dynamic property AmpProject\Dom\Document::$body is deprecated in /path/to/vendor/ampproject/amp-toolbox/src/Dom/Document/Filter/DeduplicateTag.php on line 65

Creation of dynamic property AmpProject\Dom\Document::$html is deprecated in /path/to/vendor/ampproject/amp-toolbox/src/Dom/Document.php on line 699

Creation of dynamic property AmpProject\Dom\Document::$xpath is deprecated in /path/to/vendor/ampproject/amp-toolbox/src/Dom/Document.php on line 687
```

Pull request:
PHP 8.2 Improvements
Fixes #532

Patch:
diff --git a/.gitignore b/.gitignore
index 3a9875b46..079465159 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 /vendor/
 composer.lock
+.phpunit.result.cache
diff --git a/src/Dom/Document.php b/src/Dom/Document.php
index 26f94a3fc..b57fa37ab 100644
--- a/src/Dom/Document.php
+++ b/src/Dom/Document.php
@@ -108,6 +108,13 @@ final class Document extends DOMDocument
      */
     const XPATH_INLINE_STYLE_ATTRIBUTES_QUERY = './/@style';
 
+    /**
+     * Associative array for lazily-created, cached properties for the document.
+     *
+     * @var array
+     */
+    private $properties = [];
+
     /**
      * Associative array of options to configure the behavior of the DOM document abstraction.
      *
@@ -142,13 +149,6 @@ final class Document extends DOMDocument
      */
     private $cssMaxByteCountEnforced = -1;
 
-    /**
-     * Resource hint manager to manage resource hint <link> tags in the <head>.
-     *
-     * @var LinkManager|null
-     */
-    private $links;
-
     /**
      * List of document filter class names.
      *
@@ -314,7 +314,7 @@ public static function fromNode(DOMNode &$node)
     private function reset()
     {
         // Drop references to old DOM document.
-        unset($this->xpath, $this->head, $this->body);
+        unset($this->properties['xpath'], $this->properties['head'], $this->properties['body']);
 
         // Reference of the document itself doesn't change here, but might need to change in the future.
         return $this;
@@ -587,6 +587,8 @@ private function normalizeDocumentStructure($content)
 
     /**
      * Normalize the structure of the document if it was already provided as a DOM.
+     *
+     * Warning: This method may not use any magic getters for html, head, or body.
      */
     public function normalizeDomStructure()
     {
@@ -618,8 +620,8 @@ public function normalizeDomStructure()
                 throw FailedToRetrieveRequiredDomElement::forHeadElement($head);
             }
 
-            $this->head = $head;
-            $html->appendChild($this->head);
+            $this->properties['head'] = $head;
+            $html->appendChild($head);
 
             if ($oldDocumentElement->nodeName === Tag::BODY) {
                 $body = $oldDocumentElement;
@@ -634,23 +636,23 @@ public function normalizeDomStructure()
                 throw FailedToRetrieveRequiredDomElement::forBodyElement($body);
             }
 
-            $this->body = $body;
-            $html->appendChild($this->body);
+            $this->properties['body'] = $body;
+            $html->appendChild($body);
 
-            if ($oldDocumentElement !== $this->body && $oldDocumentElement !== $this->head) {
-                $this->body->appendChild($oldDocumentElement);
+            if ($oldDocumentElement !== $body && $oldDocumentElement !== $this->head) {
+                $body->appendChild($oldDocumentElement);
             }
         } else {
             $head = $this->getElementsByTagName(Tag::HEAD)->item(0);
             if (!$head) {
-                $this->head = $this->createElement(Tag::HEAD);
-                $this->documentElement->insertBefore($this->head, $this->documentElement->firstChild);
+                $this->properties['head'] = $this->createElement(Tag::HEAD);
+                $this->documentElement->insertBefore($this->properties['head'], $this->documentElement->firstChild);
             }
 
             $body = $this->getElementsByTagName(Tag::BODY)->item(0);
             if (!$body) {
-                $this->body = $this->createElement(Tag::BODY);
-                $this->documentElement->appendChild($this->body);
+                $this->properties['body'] = $this->createElement(Tag::BODY);
+                $this->documentElement->appendChild($this->properties['body']);
             }
         }
 
@@ -660,15 +662,20 @@ public function normalizeDomStructure()
 
     /**
      * Move invalid head nodes back to the body.
+     *
+     * Warning: This method may not use any magic getters for html, head, or body.
      */
     private function moveInvalidHeadNodesToBody()
     {
         // Walking backwards makes it easier to move elements in the expected order.
-        $node = $this->head->lastChild;
+        $node = $this->properties['head']->lastChild;
         while ($node) {
             $nextSibling = $node->previousSibling;
-            if (! $this->isValidHeadNode($node)) {
-                $this->body->insertBefore($this->head->removeChild($node), $this->body->firstChild);
+            if (!$this->isValidHeadNode($node)) {
+                $this->properties['body']->insertBefore(
+                    $this->properties['head']->removeChild($node),
+                    $this->properties['body']->firstChild
+                );
             }
             $node = $nextSibling;
         }
@@ -681,12 +688,14 @@ private function moveInvalidHeadNodesToBody()
      * the </body> not valid in AMP, but trailing elements after </html> will get wrapped in additional <html> elements.
      * While comment nodes would be allowed in AMP, everything is moved regardless so that source stack comments will
      * retain their relative position with the element nodes they annotate.
+     *
+     * Warning: This method may not use any magic getters for html, head, or body.
      */
     private function movePostBodyNodesToBody()
     {
         // Move nodes (likely comments) from after the </body>.
-        while ($this->body->nextSibling) {
-            $this->body->appendChild($this->body->nextSibling);
+        while ($this->properties['body']->nextSibling) {
+            $this->properties['body']->appendChild($this->properties['body']->nextSibling);
         }
 
         // Move nodes from after the </html>.
@@ -695,11 +704,11 @@ private function movePostBodyNodesToBody()
             if ($nextSibling instanceof Element && Tag::HTML === $nextSibling->nodeName) {
                 // Handle trailing elements getting wrapped in implicit duplicate <html>.
                 while ($nextSibling->firstChild) {
-                    $this->body->appendChild($nextSibling->firstChild);
+                    $this->properties['body']->appendChild($nextSibling->firstChild);
                 }
                 $nextSibling->parentNode->removeChild($nextSibling); // Discard now-empty implicit <html>.
             } else {
-                $this->body->appendChild($this->documentElement->nextSibling);
+                $this->properties['body']->appendChild($this->documentElement->nextSibling);
             }
         }
     }
@@ -707,6 +716,8 @@ private function movePostBodyNodesToBody()
     /**
      * Determine whether a node can be in the head.
      *
+     * Warning: This method may not use any magic getters for html, head, or body.
+     *
      * @link https://github.com/ampproject/amphtml/blob/445d6e3be8a5063e2738c6f90fdcd57f2b6208be/validator/engine/htmlparser.js#L83-L100
      * @link https://www.w3.org/TR/html5/document-metadata.html
      *
@@ -787,7 +798,7 @@ public function addAmpCustomStyle($style)
         }
 
         $this->ampCustomStyle->textContent = $newStyle;
-        $this->ampCustomStyleByteCount     = $newByteCount;
+        $this->properties['ampCustomStyleByteCount'] = $newByteCount;
     }
 
     /**
@@ -818,6 +829,29 @@ public function getRemainingCustomCssSpace()
         );
     }
 
+    /**
+     * Get the array of allowed keys of lazily-created, cached properties.
+     * The array index is the key and the array value is the key's default value.
+     *
+     * @return array Array of allowed keys.
+     */
+    protected function getAllowedKeys()
+    {
+        return [
+            'xpath',
+            Tag::HTML,
+            Tag::HEAD,
+            Tag::BODY,
+            Attribute::CHARSET,
+            Attribute::VIEWPORT,
+            'ampElements',
+            'ampCustomStyle',
+            'ampCustomStyleByteCount',
+            'inlineStyleByteCount',
+            'links',
+        ];
+    }
+
     /**
      * Magic getter to implement lazily-created, cached properties for the document.
      *
@@ -828,8 +862,8 @@ public function __get($name)
     {
         switch ($name) {
             case 'xpath':
-                $this->xpath = new DOMXPath($this);
-                return $this->xpath;
+                $this->properties['xpath'] = new DOMXPath($this);
+                return $this->properties['xpath'];
             case Tag::HTML:
                 $html = $this->getElementsByTagName(Tag::HTML)->item(0);
 
@@ -843,8 +877,8 @@ public function __get($name)
                     throw FailedToRetrieveRequiredDomElement::forHtmlElement($html);
                 }
 
-                $this->html = $html;
-                return $this->html;
+                $this->properties['html'] = $html;
+                return $this->properties['html'];
             case Tag::HEAD:
                 $head = $this->getElementsByTagName(Tag::HEAD)->item(0);
 
@@ -858,8 +892,8 @@ public function __get($name)
                     throw FailedToRetrieveRequiredDomElement::forHeadElement($head);
                 }
 
-                $this->head = $head;
-                return $this->head;
+                $this->properties['head'] = $head;
+                return $this->properties['head'];
             case Tag::BODY:
                 $body = $this->getElementsByTagName(Tag::BODY)->item(0);
 
@@ -873,8 +907,8 @@ public function __get($name)
                     throw FailedToRetrieveRequiredDomElement::forBodyElement($body);
                 }
 
-                $this->body = $body;
-                return $this->body;
+                $this->properties['body'] = $body;
+                return $this->properties['body'];
             case Attribute::CHARSET:
                 // This is not cached as it could potentially be requested too early, before the viewport was added, and
                 // the cache would then store null without rechecking later on after the viewport has been added.
@@ -915,46 +949,47 @@ public function __get($name)
                     $this->head->appendChild($ampCustomStyle);
                 }
 
-                $this->ampCustomStyle = $ampCustomStyle;
+                $this->properties['ampCustomStyle'] = $ampCustomStyle;
 
-                return $this->ampCustomStyle;
+                return $this->properties['ampCustomStyle'];
 
             case 'ampCustomStyleByteCount':
-                if (!isset($this->ampCustomStyle)) {
+                if (!isset($this->properties['ampCustomStyle'])) {
                     $ampCustomStyle = $this->xpath->query(self::XPATH_AMP_CUSTOM_STYLE_QUERY, $this->head)->item(0);
                     if (!$ampCustomStyle instanceof Element) {
                         return 0;
-                    } else {
-                        $this->ampCustomStyle = $ampCustomStyle;
                     }
+
+                    $this->properties['ampCustomStyle'] = $ampCustomStyle;
                 }
 
-                if (!isset($this->ampCustomStyleByteCount)) {
-                    $this->ampCustomStyleByteCount = strlen($this->ampCustomStyle->textContent);
+                if (!isset($this->properties['ampCustomStyleByteCount'])) {
+                    $this->properties['ampCustomStyleByteCount'] =
+                        strlen($this->properties['ampCustomStyle']->textContent);
                 }
 
-                return $this->ampCustomStyleByteCount;
+                return $this->properties['ampCustomStyleByteCount'];
 
             case 'inlineStyleByteCount':
-                if (!isset($this->inlineStyleByteCount)) {
-                    $this->inlineStyleByteCount = 0;
+                if (!isset($this->properties['inlineStyleByteCount'])) {
+                    $this->properties['inlineStyleByteCount'] = 0;
                     $attributes = $this->xpath->query(
                         self::XPATH_INLINE_STYLE_ATTRIBUTES_QUERY,
                         $this->documentElement
                     );
                     foreach ($attributes as $attribute) {
-                        $this->inlineStyleByteCount += strlen($attribute->textContent);
+                        $this->properties['inlineStyleByteCount'] += strlen($attribute->textContent);
                     }
                 }
 
-                return $this->inlineStyleByteCount;
+                return $this->properties['inlineStyleByteCount'];
 
             case 'links':
-                if (! isset($this->links)) {
-                    $this->links = new LinkManager($this);
+                if (! isset($this->properties['links'])) {
+                    $this->properties['links'] = new LinkManager($this);
                 }
 
-                return $this->links;
+                return $this->properties['links'];
         }
 
         // Mimic regular PHP behavior for missing notices.
@@ -962,6 +997,39 @@ public function __get($name)
         return null;
     }
 
+    /**
+     * Magic setter to implement lazily-created, cached properties for the document.
+     *
+     * @param string $name Name of the property to set.
+     * @param mixed  $value Value of the property.
+     */
+    public function __set($name, $value)
+    {
+        if (!in_array($name, $this->getAllowedKeys(), true)) {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error(self::PROPERTY_GETTER_ERROR_MESSAGE . $name, E_USER_NOTICE);
+            return;
+        }
+
+        $this->properties[$name] = $value;
+    }
+
+    /**
+     * Magic callback for lazily-created, cached properties for the document.
+     *
+     * @param string $name Name of the property to set.
+     */
+    public function __isset($name)
+    {
+        if (!in_array($name, $this->getAllowedKeys(), true)) {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error(self::PROPERTY_GETTER_ERROR_MESSAGE . $name, E_USER_NOTICE);
+            return false;
+        }
+
+        return isset($this->properties[$name]);
+    }
+
     /**
      * Make sure we properly reinitialize on clone.
      *
diff --git a/src/Dom/Element.php b/src/Dom/Element.php
index 238db47d3..28d6d93b3 100644
--- a/src/Dom/Element.php
+++ b/src/Dom/Element.php
@@ -39,6 +39,13 @@ final class Element extends DOMElement
      */
     const PROPERTY_GETTER_ERROR_MESSAGE = 'Undefined property: AmpProject\\Dom\\Element::';
 
+    /**
+     * Associative array for lazily-created, cached properties for the document.
+     *
+     * @var array
+     */
+    private $properties = [];
+
     /**
      * Add CSS styles to the element as an inline style attribute.
      *
@@ -268,11 +275,11 @@ public function __get($name)
     {
         switch ($name) {
             case 'inlineStyleByteCount':
-                if (!isset($this->inlineStyleByteCount)) {
-                    $this->inlineStyleByteCount = strlen((string)$this->getAttribute(Attribute::STYLE));
+                if (!isset($this->properties['inlineStyleByteCount'])) {
+                    $this->properties['inlineStyleByteCount'] = strlen((string)$this->getAttribute(Attribute::STYLE));
                 }
 
-                return $this->inlineStyleByteCount;
+                return $this->properties['inlineStyleByteCount'];
         }
 
         // Mimic regular PHP behavior for missing notices.
@@ -280,4 +287,37 @@ public function __get($name)
 
         return null;
     }
+
+    /**
+     * Magic setter to implement lazily-created, cached properties for the element.
+     *
+     * @param string $name Name of the property to set.
+     * @param mixed  $value Value of the property.
+     */
+    public function __set($name, $value)
+    {
+        if ($name !== 'inlineStyleByteCount') {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error(self::PROPERTY_GETTER_ERROR_MESSAGE . $name, E_USER_NOTICE);
+            return;
+        }
+
+        $this->properties[$name] = $value;
+    }
+
+    /**
+     * Magic callback for lazily-created, cached properties for the element.
+     *
+     * @param string $name Name of the property to set.
+     */
+    public function __isset($name)
+    {
+        if ($name !== 'inlineStyleByteCount') {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error(self::PROPERTY_GETTER_ERROR_MESSAGE . $name, E_USER_NOTICE);
+            return false;
+        }
+
+        return isset($this->properties[$name]);
+    }
 }
diff --git a/src/Optimizer/Configuration/BaseTransformerConfiguration.php b/src/Optimizer/Configuration/BaseTransformerConfiguration.php
index 3cac2d4d4..19f461b2a 100644
--- a/src/Optimizer/Configuration/BaseTransformerConfiguration.php
+++ b/src/Optimizer/Configuration/BaseTransformerConfiguration.php
@@ -23,6 +23,13 @@ abstract class BaseTransformerConfiguration implements TransformerConfiguration
      */
     private $allowedKeys;
 
+    /**
+     * Associative array of configuration data.
+     *
+     * @var array
+     */
+    private $configuration = [];
+
     /**
      * Instantiate an AmpRuntimeCssConfiguration object.
      *
@@ -37,7 +44,7 @@ public function __construct($configuration = [])
             if (! array_key_exists($key, $this->allowedKeys)) {
                 throw InvalidConfigurationKey::fromTransformerKey(static::class, $key);
             }
-            $this->$key = $this->validate($key, $value);
+            $this->configuration[$key] = $this->validate($key, $value);
         }
     }
 
@@ -58,7 +65,62 @@ public function get($key)
         }
 
         // At this point, the configuration should either have received this value or filled it with a default.
-        return $this->$key;
+        return $this->configuration[$key];
+    }
+
+    /**
+     * Magic getter to get value for a given key.
+     *
+     * Mostly for backward compatibility.
+     *
+     * @param string $name Name of the property to set.
+     */
+    public function __get($name)
+    {
+        if (! array_key_exists($name, $this->allowedKeys)) {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error('Undefined property: ' . get_class($this) . '::$' . $name, E_USER_NOTICE);
+            return null;
+        }
+
+        return $this->configuration[$name];
+    }
+
+    /**
+     * Magic setter for configurations.
+     *
+     * Mostly for backward compatibility.
+     *
+     * @param string $name Name of the property to set.
+     * @param mixed  $value Value of the property.
+     */
+    public function __set($name, $value)
+    {
+        if (! array_key_exists($name, $this->allowedKeys)) {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error('Undefined property: ' . get_class($this) . '::$' . $name, E_USER_NOTICE);
+            return;
+        }
+
+        $this->configuration[$name] = $value;
+    }
+
+    /**
+     * Magic method to check whether a configuration exists.
+     *
+     * Mostly for backward compatibility.
+     *
+     * @param string $name Name of the property to set.
+     */
+    public function __isset($name)
+    {
+        if (! array_key_exists($name, $this->allowedKeys)) {
+            // Mimic regular PHP behavior for missing notices.
+            trigger_error('Undefined property: ' . get_class($this) . '::$' . $name, E_USER_NOTICE);
+            return false;
+        }
+
+        return isset($this->configuration[$name]);
     }
 
     /**
@@ -71,7 +133,7 @@ public function toArray()
         $configArray = [];
 
         foreach (array_keys($this->allowedKeys) as $key) {
-            $configArray[$key] = $this->$key;
+            $configArray[$key] = $this->configuration[$key];
         }
 
         return $configArray;
diff --git a/src/Optimizer/Transformer/MinifyHtml.php b/src/Optimizer/Transformer/MinifyHtml.php
index db5a5a595..5e45c8408 100644
--- a/src/Optimizer/Transformer/MinifyHtml.php
+++ b/src/Optimizer/Transformer/MinifyHtml.php
@@ -98,7 +98,7 @@ private function minifyNode(DOMNode $node, ErrorCollection $errors, $canCollapse
         }
 
         // Update options based on the current node.
-        if (isset($node->tagName)) {
+        if ($node instanceof Element) {
             if ($canCollapseWhitespace && !$this->canCollapseWhitespace($node->tagName)) {
                 $canCollapseWhitespace = false;
             }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9eaa83e3b..1f0850d5f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,7 +12,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        php: ['8.0', '7.4', '7.3', '7.2', '7.1', '7.0', '5.6']
+        php: ['8.1','8.0', '7.4', '7.3', '7.2', '7.1', '7.0', '5.6']
         coverage: [false]
         random: [false]
         include:
@@ -20,7 +20,7 @@ jobs:
             coverage: true
           - php: '8.0'
             random: true
-          - php: '8.1'
+          - php: '8.2'
             experimental: true
 
     steps:
diff --git a/tests/Dom/DocumentTest.php b/tests/Dom/DocumentTest.php
index dc02277fa..b83536aac 100644
--- a/tests/Dom/DocumentTest.php
+++ b/tests/Dom/DocumentTest.php
@@ -246,42 +246,42 @@ public function dataDomDocument()
             ],
             'iso_8859_1_encoding_predefined'           => [
                 'iso-8859-1',
-                utf8_decode('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'iso_8859_1_encoding_predefined_uc'        => [
                 'ISO-8859-1',
-                utf8_decode('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'iso_8859_1_encoding_auto'                 => [
                 'iso-8859-1',
-                utf8_decode('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><head></head><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'iso_8859_1_encoding_guessed_via_charset'  => [
                 '',
-                utf8_decode('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1" /></head><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1" /></head><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'iso_8859_1_encoding_guessed_via_wrong_charset'  => [
                 '',
-                utf8_decode('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=something-else" /></head><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=something-else" /></head><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'iso_8859_1_encoding_guessed_via_content'  => [
                 '',
-                utf8_decode('<!DOCTYPE html><html><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'mapping_encodings_to_fallbacks'  => [
                 'latin-1',
-                utf8_decode('<!DOCTYPE html><html><body><p>ÄÖÜ</p></body></html>'),
+                mb_convert_encoding('<!DOCTYPE html><html><body><p>ÄÖÜ</p></body></html>', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body><p>ÄÖÜ</p></body></html>',
             ],
             'raw_iso_8859_1'                           => [
                 '',
-                utf8_decode('ÄÖÜ'),
+                mb_convert_encoding('ÄÖÜ', 'ISO-8859-1', 'UTF-8'),
                 '<!DOCTYPE html><html>' . $head . '<body>ÄÖÜ</body></html>',
             ],
             // Make sure we correctly identify the ISO-8859 sub-charsets ("€" does not exist in ISO-8859-1).
diff --git a/tests/Optimizer/ImageDimensionsTest.php b/tests/Optimizer/ImageDimensionsTest.php
index 16b279296..a28c46eb4 100644
--- a/tests/Optimizer/ImageDimensionsTest.php
+++ b/tests/Optimizer/ImageDimensionsTest.php
@@ -241,8 +241,9 @@ public function testItCanGetNumericDimensions(
 
         $imageDimensions = new ImageDimensions($image);
 
-        $this->assertEquals($expectedNumericWidth, $imageDimensions->getNumericWidth());
-        $this->assertEquals($expectedNumericHeight, $imageDimensions->getNumericHeight());
+        $delta = 0.00000000000001;
+        $this->assertEqualsWithDelta($expectedNumericWidth, $imageDimensions->getNumericWidth(), $delta);
+        $this->assertEqualsWithDelta($expectedNumericHeight, $imageDimensions->getNumericHeight(), $delta);
     }
 
     /**