diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54227bb26..863161acb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: max-parallel: 12 matrix: - php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] package-release: [dist] extensions: ['gd'] include: @@ -27,7 +27,7 @@ jobs: extensions: 'imagick' steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 diff --git a/README.md b/README.md index 7546e807e..bdde40477 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ Note that some required dependencies may have further dependencies ### Recommendations - * OPcache (OPcache, XCache, APC, etc.): improves performance * GD (for image processing) - * IMagick or GMagick extension: improves image processing performance + * Additionally, the IMagick or GMagick extension improves image processing performance for certain image types + * OPcache (OPcache, XCache, APC, etc.): improves performance Visit the wiki for more information: https://github.com/dompdf/dompdf/wiki/Requirements @@ -224,6 +224,8 @@ Files accessed through the local file system have the following requirement: Watch https://github.com/dompdf/dompdf/issues/320 for progress * Does not support CSS flexbox. * Does not support CSS Grid. + * A single Dompdf instance should not be used to render more than one HTML document + because persisted parsing and rendering artifacts can impact future renders. --- [![Donate button](https://www.paypal.com/en_US/i/btn/btn_donate_SM.gif)](http://goo.gl/DSvWf) diff --git a/composer.json b/composer.json index 0b290dfc8..bcb74b57c 100644 --- a/composer.json +++ b/composer.json @@ -35,10 +35,10 @@ "ext-gd": "*", "ext-json": "*", "ext-zip": "*", - "phpunit/phpunit": "^7.5 || ^8 || ^9", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10", "squizlabs/php_codesniffer": "^3.5", "mockery/mockery": "^1.3", - "symfony/process": "^4.4 || ^5.4 || ^6.2" + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" }, "suggest": { "ext-gd": "Needed to process images", diff --git a/lib/Cpdf.php b/lib/Cpdf.php index e6130cadd..f45a6799e 100644 --- a/lib/Cpdf.php +++ b/lib/Cpdf.php @@ -3551,7 +3551,7 @@ private function openFont($font) $cache_name = "$metrics_name.json"; $this->addMessage("metrics: $metrics_name, cache: $cache_name"); - + if (file_exists($fontcache . '/' . $cache_name)) { $this->addMessage("openFont: json metrics file exists $fontcache/$cache_name"); $cached_font_info = json_decode(file_get_contents($fontcache . '/' . $cache_name), true); @@ -3921,6 +3921,8 @@ function setColor($color, $force = false) } /** + * sets the color for fill operations + * * @param string $fillRule */ function setFillRule($fillRule) @@ -5944,8 +5946,10 @@ protected function addImagePngAlpha($file, $x, $y, $w, $h, $byte) } } - $imagick = new \Imagick($file); - $imagick->setFormat('png'); + $imagick = new \Imagick(); + $imagick->setRegistry('temporary-path', $this->tmp); + $imagick->setFormat('PNG'); + $imagick->readImage($file); // Get opacity channel (negative of alpha channel) if ($imagick->getImageAlphaChannel()) { @@ -5955,7 +5959,14 @@ protected function addImagePngAlpha($file, $x, $y, $w, $h, $byte) if (\Imagick::getVersion()['versionNumber'] < 1800) { $alpha_channel->negateImage(true); } - $alpha_channel->writeImage($tempfile_alpha); + + try { + $alpha_channel->writeImage($tempfile_alpha); + } catch (\ImagickException $th) { + // Backwards compatible retry attempt in case the IMagick policy is still configured in lowercase + $alpha_channel->setFormat('png'); + $alpha_channel->writeImage($tempfile_alpha); + } // Cast to 8bit+palette $imgalpha_ = @imagecreatefrompng($tempfile_alpha); @@ -5968,6 +5979,7 @@ protected function addImagePngAlpha($file, $x, $y, $w, $h, $byte) // Make opaque image $color_channels = new \Imagick(); + $color_channels->setRegistry('temporary-path', $this->tmp); $color_channels->newImage($wpx, $hpx, "#FFFFFF", "png"); $color_channels->compositeImage($imagick, \Imagick::COMPOSITE_COPYRED, 0, 0); $color_channels->compositeImage($imagick, \Imagick::COMPOSITE_COPYGREEN, 0, 0); @@ -6125,8 +6137,7 @@ function addPngFromFile($file, $x, $y, $w = 0, $h = 0) } /** - * add a PNG image into the document, from a file - * this should work with remote files + * add an SVG image into the document from a file * * @param $file * @param $x diff --git a/src/Adapter/CPDF.php b/src/Adapter/CPDF.php index ad6d5410d..45712a229 100644 --- a/src/Adapter/CPDF.php +++ b/src/Adapter/CPDF.php @@ -602,11 +602,10 @@ protected function _convert_to_png($image_url, $type) set_error_handler([Helpers::class, "record_warnings"]); - if (!function_exists($func_name)) { - if (!method_exists(Helpers::class, $func_name)) { - throw new Exception("Function $func_name() not found. Cannot convert $type image: $image_url. Please install the image PHP extension."); - } + if (method_exists(Helpers::class, $func_name)) { $func_name = [Helpers::class, $func_name]; + } elseif (!function_exists($func_name)) { + throw new Exception("Function $func_name() not found. Cannot convert $type image: $image_url. Please install the image PHP extension."); } try { @@ -688,7 +687,6 @@ public function select($x, $y, $w, $h, $font, $size, $color = [0, 0, 0], $opts = { $pdf = $this->_pdf; - $font .= ".afm"; $pdf->selectFont($font); if (!isset($pdf->acroFormId)) { @@ -706,7 +704,6 @@ public function textarea($x, $y, $w, $h, $font, $size, $color = [0, 0, 0]) { $pdf = $this->_pdf; - $font .= ".afm"; $pdf->selectFont($font); if (!isset($pdf->acroFormId)) { @@ -723,7 +720,6 @@ public function input($x, $y, $w, $h, $type, $font, $size, $color = [0, 0, 0]) { $pdf = $this->_pdf; - $font .= ".afm"; $pdf->selectFont($font); if (!isset($pdf->acroFormId)) { @@ -756,7 +752,7 @@ public function text($x, $y, $text, $font, $size, $color = [0, 0, 0], $word_spac $this->_set_fill_color($color); $is_font_subsetting = $this->_dompdf->getOptions()->getIsFontSubsettingEnabled(); - $pdf->selectFont($font . '.afm', '', true, $is_font_subsetting); + $pdf->selectFont($font, '', true, $is_font_subsetting); $pdf->addText($x, $this->y($y) - $pdf->getFontHeight($size), $size, $text, $angle, $word_space, $char_space); @@ -790,37 +786,64 @@ public function add_link($url, $x, $y, $width, $height) } } - public function font_supports_text(string $font, string $text): bool + public function font_supports_char(string $font, string $char): bool { - if ($text === "") { + if ($char === "") { return true; } - $is_font_subsetting = $this->_dompdf->getOptions()->getIsFontSubsettingEnabled(); - $this->_pdf->selectFont($font, '', false, $is_font_subsetting); - if (!array_key_exists($font, $this->_pdf->fonts)) { + $subsetting = $this->_dompdf->getOptions()->getIsFontSubsettingEnabled(); + $this->_pdf->selectFont($font, '', false, $subsetting); + if (!\array_key_exists($font, $this->_pdf->fonts)) { return false; } - $font_info = $this->_pdf->fonts[$font]; - - if (function_exists("mb_str_split")) { - $chars = array_unique(mb_str_split($text, 1, "UTF-8"), SORT_STRING); - } else { - $chars = array_unique(preg_split("//u", $text, -1, PREG_SPLIT_NO_EMPTY), SORT_STRING); - } - $char_codes = array_map( - function($char) { - return Helpers::uniord($char, "UTF-8"); - }, - $chars - ); - - foreach ($char_codes as $char_code) { - if (!array_key_exists($char_code, $font_info['C'])) { + $fontInfo = $this->_pdf->fonts[$font]; + $charCode = Helpers::uniord($char, "UTF-8"); + + if (!$fontInfo["isUnicode"]) { + // The core fonts use Windows ANSI encoding. The char map uses the + // position of the character in the encoding's mapping table in this + // case, not the Unicode code point, which is different for the + // characters outside ISO-8859-1 (positions 0x80-0x9F) + // https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/WINDOWS/CP1252.TXT + $mapping = [ + 0x20AC => 0x80, + 0x201A => 0x82, + 0x0192 => 0x83, + 0x201E => 0x84, + 0x2026 => 0x85, + 0x2020 => 0x86, + 0x2021 => 0x87, + 0x02C6 => 0x88, + 0x2030 => 0x89, + 0x0160 => 0x8A, + 0x2039 => 0x8B, + 0x0152 => 0x8C, + 0x017D => 0x8E, + 0x2018 => 0x91, + 0x2019 => 0x92, + 0x201C => 0x93, + 0x201D => 0x94, + 0x2022 => 0x95, + 0x2013 => 0x96, + 0x2014 => 0x97, + 0x02DC => 0x98, + 0x2122 => 0x99, + 0x0161 => 0x9A, + 0x203A => 0x9B, + 0x0153 => 0x9C, + 0x017E => 0x9E, + 0x0178 => 0x9F + ]; + + $charCode = $mapping[$charCode] ?? $charCode; + + if ($charCode > 0xFF) { return false; } } - return true; + + return \array_key_exists($charCode, $fontInfo["C"]); } /** diff --git a/src/Adapter/GD.php b/src/Adapter/GD.php index 0e1e7a50f..fa94163b1 100644 --- a/src/Adapter/GD.php +++ b/src/Adapter/GD.php @@ -617,11 +617,10 @@ public function image($img, $x, $y, $w, $h, $resolution = "normal") } $func_name = "imagecreatefrom$img_type"; - if (!function_exists($func_name)) { - if (!method_exists(Helpers::class, $func_name)) { - throw new \Exception("Function $func_name() not found. Cannot convert $img_type image: $img. Please install the image PHP extension."); - } + if (method_exists(Helpers::class, $func_name)) { $func_name = [Helpers::class, $func_name]; + } elseif (!function_exists($func_name)) { + throw new \Exception("Function $func_name() not found. Cannot convert $img_type image: $img. Please install the image PHP extension."); } $src = @call_user_func($func_name, $img); @@ -799,32 +798,17 @@ private function getCharMap(string $font) return $unicodeCharMapTables[$font] = $char_map; } - public function font_supports_text(string $font, string $text): bool + public function font_supports_char(string $font, string $char): bool { - if ($text === "") { + if ($char === "") { return true; } - if (function_exists("mb_str_split")) { - $chars = array_unique(mb_str_split($text, 1, "UTF-8"), SORT_STRING); - } else { - $chars = array_unique(preg_split("//u", $text, -1, PREG_SPLIT_NO_EMPTY), SORT_STRING); - } - $char_codes = array_map( - function($char) { - return Helpers::uniord($char, "UTF-8"); - }, - $chars - ); - - $char_map = $this->getCharMap($font); - - foreach ($char_codes as $char_code) { - if (!array_key_exists($char_code, $char_map)) { - return false; - } - } - return true; + $font = $this->get_ttf_file($font); + $charMap = $this->getCharMap($font); + $charCode = Helpers::uniord($char, "UTF-8"); + + return \array_key_exists($charCode, $charMap); } public function get_text_width($text, $font, $size, $word_spacing = 0.0, $char_spacing = 0.0) diff --git a/src/Adapter/PDFLib.php b/src/Adapter/PDFLib.php index e5f478a5b..1964d60ef 100644 --- a/src/Adapter/PDFLib.php +++ b/src/Adapter/PDFLib.php @@ -58,7 +58,7 @@ class PDFLib implements Canvas * * @var array */ - public static $nativeFontsTpPDFLib = [ + public static $nativeFontsToPDFLib = [ "courier" => "Courier", "courier-bold" => "Courier-Bold", "courier-oblique" => "Courier-Oblique", @@ -208,10 +208,11 @@ public function __construct($paper = "letter", string $orientation = "portrait", } else { $this->_dompdf = $dompdf; } + $options = $dompdf->getOptions(); $this->_pdf = new \PDFLib(); - $license = $dompdf->getOptions()->getPdflibLicense(); + $license = $options->getPdflibLicense(); if (strlen($license) > 0) { $this->setPDFLibParameter("license", $license); } @@ -227,9 +228,9 @@ public function __construct($paper = "letter", string $orientation = "portrait", $this->setPDFLibParameter("fontwarning", "false"); } - $searchPath = $this->_dompdf->getOptions()->getFontDir(); + $searchPath = [$options->getFontDir(), $options->getRootDir() . "/lib/fonts"]; if (empty($searchPath) === false) { - $this->_pdf->set_option('searchpath={' . $searchPath . '}'); + $this->_pdf->set_option('searchpath={{' . implode("} {", $searchPath) . '}}'); } // fetch PDFLib version information for the producer field @@ -244,7 +245,7 @@ public function __construct($paper = "letter", string $orientation = "portrait", if (self::$IN_MEMORY) { $this->_pdf->begin_document("", ""); } else { - $tmp_dir = $this->_dompdf->getOptions()->getTempDir(); + $tmp_dir = $options->getTempDir(); $tmp_name = @tempnam($tmp_dir, "libdompdf_pdf_"); @unlink($tmp_name); $this->_file = "$tmp_name.pdf"; @@ -316,7 +317,7 @@ public function open_object() { $this->_pdf->suspend_page(""); if ($this->getPDFLibMajorVersion() >= 7) { - $ret = $this->_pdf->begin_template_ext($this->_width, $this->_height, null); + $ret = $this->_pdf->begin_template_ext($this->_width, $this->_height, ""); } else { $ret = $this->_pdf->begin_template($this->_width, $this->_height); } @@ -597,10 +598,10 @@ protected function _set_stroke_color($color) list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], $color[3]]; } elseif (isset($color[2])) { $type = "rgb"; - list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], null]; + list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], 0]; } else { $type = "gray"; - list($c1, $c2, $c3, $c4) = [$color[0], $color[1], null, null]; + list($c1, $c2, $c3, $c4) = [$color[0], $color[1], 0, 0]; } $this->_set_stroke_opacity($alpha, "Normal"); @@ -634,10 +635,10 @@ protected function _set_fill_color($color) list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], $color[3]]; } elseif (isset($color[2])) { $type = "rgb"; - list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], null]; + list($c1, $c2, $c3, $c4) = [$color[0], $color[1], $color[2], 0]; } else { $type = "gray"; - list($c1, $c2, $c3, $c4) = [$color[0], $color[1], null, null]; + list($c1, $c2, $c3, $c4) = [$color[0], $color[1], 0, 0]; } $this->_set_fill_opacity($alpha, "Normal"); @@ -722,18 +723,17 @@ public function set_default_view($view, $options = []) */ protected function _load_font($font, $encoding = null, $options = "") { - // Fix for PDFLibs case-sensitive font names + // Fix for PDFLib's case-sensitive font names $baseFont = basename($font); $isNativeFont = false; - $lcBaseFont = strtolower($basefont); - if (isset(self::$nativeFontsTpPDFLib[$lcBaseFont])) { - $font = self::$nativeFontsTpPDFLib[$lcBaseFont]; + $lcBaseFont = strtolower($baseFont); + if (isset(self::$nativeFontsToPDFLib[$lcBaseFont])) { + $baseFont = self::$nativeFontsToPDFLib[$lcBaseFont]; $isNativeFont = true; } // Embed non-native fonts - if ($isNativeFont) { - // Embed non-native fonts + if (!$isNativeFont) { $options .= " embedding=true"; } @@ -756,12 +756,12 @@ protected function _load_font($font, $encoding = null, $options = "") // Native fonts are build in, just load it if ($isNativeFont) { - $this->_fonts[$key] = $this->_pdf->load_font($font, $encoding, $options); + $this->_fonts[$key] = $this->_pdf->load_font($baseFont, $encoding, $options); return $this->_fonts[$key]; } $fontOutline = $this->getPDFLibParameter("FontOutline", 1); - if ($fontOutline === "" || $fontOutline <= 0) { + if ($fontOutline === "" || $fontOutline < 0) { $families = $this->_dompdf->getFontMetrics()->getFontFamilies(); foreach ($families as $files) { foreach ($files as $file) { @@ -893,7 +893,6 @@ public function clipping_rectangle($x1, $y1, $w, $h) public function clipping_roundrectangle($x1, $y1, $w, $h, $rTL, $rTR, $rBR, $rBL) { if ($this->getPDFLibMajorVersion() < 9) { - //TODO: add PDFLib7 support $this->clipping_rectangle($x1, $y1, $w, $h); return; } @@ -1052,6 +1051,59 @@ public function circle($x, $y, $r, $color, $width = null, $style = [], $fill = f $this->_set_stroke_opacity($this->_current_opacity, "Normal"); } + /** + * Convert image to a PNG image + * + * @param string $image_url + * @param string $type + * + * @return string|null The url of the newly converted image + */ + protected function _convert_to_png($image_url, $type) + { + $filename = Cache::getTempImage($image_url); + + if ($filename !== null && file_exists($filename)) { + return $filename; + } + + $func_name = "imagecreatefrom$type"; + + set_error_handler([Helpers::class, "record_warnings"]); + + if (method_exists(Helpers::class, $func_name)) { + $func_name = [Helpers::class, $func_name]; + } elseif (!function_exists($func_name)) { + throw new Exception("Function $func_name() not found. Cannot convert $type image: $image_url. Please install the image PHP extension."); + } + + try { + $im = call_user_func($func_name, $image_url); + + if ($im) { + imageinterlace($im, false); + + $tmp_dir = $this->_dompdf->getOptions()->getTempDir(); + $tmp_name = @tempnam($tmp_dir, "{$type}_dompdf_img_"); + @unlink($tmp_name); + $filename = "$tmp_name.png"; + + imagepng($im, $filename); + imagedestroy($im); + } else { + $filename = null; + } + } finally { + restore_error_handler(); + } + + if ($filename !== null) { + Cache::addTempImage($image_url, $filename); + } + + return $filename; + } + public function image($img, $x, $y, $w, $h, $resolution = "normal") { $w = (int)$w; @@ -1065,11 +1117,37 @@ public function image($img, $x, $y, $w, $h, $resolution = "normal") } if (!isset($this->_imgs[$img])) { - if (strtolower($img_type) === "svg") { - //FIXME: PDFLib loads SVG but returns error message "Function must not be called in 'page' scope" - $image_load_response = $this->_pdf->load_graphics($img_type, $img, ""); - } else { - $image_load_response = $this->_pdf->load_image($img_type, $img, ""); + switch (strtolower($img_type)) { + case "webp": + $img = $this->_convert_to_png($img, $img_type); + if ($img === null) { + $img = Cache::$broken_image; + } + $this->image($img, $x, $y, $w, $h, $resolution); + return; + case "gif": + if ($this->getPDFLibMajorVersion() >= 10) { + $img = $this->_convert_to_png($img, $img_type); + if ($img === null) { + $img = Cache::$broken_image; + } + $this->image($img, $x, $y, $w, $h, $resolution); + return; + } + case "bmp": + /** @noinspection PhpMissingBreakStatementInspection */ + case "jpeg": + /** @noinspection PhpMissingBreakStatementInspection */ + case "png": + $image_load_response = $this->_pdf->load_image($img_type, $img, ""); + break; + case "svg": + $image_load_response = $this->_pdf->load_graphics($img_type, $img, ""); + break; + default: + // not handled + $this->image(Cache::$broken_image, $x, $y, $w, $h, $resolution); + return; } if ($image_load_response === 0) { //TODO: should do something with the error message @@ -1143,9 +1221,9 @@ public function add_link($url, $x, $y, $width, $height) } } - public function font_supports_text(string $font, string $text): bool + public function font_supports_char(string $font, string $char): bool { - if ($text === "") { + if ($char === "") { return true; } @@ -1155,22 +1233,14 @@ public function font_supports_text(string $font, string $text): bool } $this->_pdf->setfont($fh, 10); - if (function_exists("mb_str_split")) { - $chars = array_unique(mb_str_split($text, 1, "UTF-8"), SORT_STRING); - } else { - $chars = array_unique(preg_split("//u", $text, -1, PREG_SPLIT_NO_EMPTY), SORT_STRING); - } - foreach ($chars as $char) { - $options = "unicode=$char"; - if ($char === " ") { - $options = "glyphname=space"; - } - $glyphid = (int)($this->_pdf->info_font($fh, "glyphid", $options)); - if ($glyphid === -1) { - return false; - } - } - return true; + // unicode character glyph id lookup supports both the character and the unicode ordinal value + // because some characters can not be specified directly we'll specify the ordinal for all characters + // known problematic characters: "{", "}", " ", "=", "\u{feff}" + $char_code = Helpers::uniord($char, "UTF-8"); + $options = "unicode=$char_code"; + $glyphid = (int) $this->_pdf->info_font($fh, "glyphid", $options); + + return $glyphid !== -1; } public function get_text_width($text, $font, $size, $word_spacing = 0.0, $letter_spacing = 0.0) diff --git a/src/Canvas.php b/src/Canvas.php index 3aa192951..7199456d3 100644 --- a/src/Canvas.php +++ b/src/Canvas.php @@ -366,14 +366,14 @@ function add_link($url, $x, $y, $width, $height); public function add_info(string $label, string $value): void; /** - * Determines if the font supports the characters in the specified text + * Determines if the font supports the given character * * @param string $font The font file to use - * @param string $text The string of characters to check + * @param string $char The character to check * * @return bool */ - function font_supports_text(string $font, string $text): bool; + function font_supports_char(string $font, string $char): bool; /** * Calculates text size, in points diff --git a/src/Cellmap.php b/src/Cellmap.php index e6c1c68e6..81b3985a3 100644 --- a/src/Cellmap.php +++ b/src/Cellmap.php @@ -591,13 +591,17 @@ public function add_frame(Frame $frame): void $style->set_used("border_bottom_width", $bottom["width"] / 2); $style->set_used("border_left_width", $left["width"] / 2); $style->set_used("border_style", "none"); - } else { - // Clear borders for rows and row groups - $style->set_used("border_width", 0); - $style->set_used("border_style", "none"); } } + if ($frame !== $this->_table) { + // Clear borders for rows and row groups. For the collapsed + // model, they have been resolved and are used by the cells now. + // For the separated model, they are ignored per spec + $style->set_used("border_width", 0); + $style->set_used("border_style", "none"); + } + if ($frame === $this->_table) { // Apply resolved borders to table cells and calculate column // widths after all frames have been added diff --git a/src/Css/Content/Url.php b/src/Css/Content/Url.php index 1d57d68cd..d1ca5a239 100644 --- a/src/Css/Content/Url.php +++ b/src/Css/Content/Url.php @@ -21,6 +21,6 @@ public function equals(ContentPart $other): bool public function __toString(): string { - return "url($this->url)"; + return "url(\"" . str_replace("\"", "\\\"", $this->url) . "\")"; } } diff --git a/src/Css/Style.php b/src/Css/Style.php index 1cd89f42d..75c688f34 100644 --- a/src/Css/Style.php +++ b/src/Css/Style.php @@ -161,7 +161,7 @@ * @property string $text_transform * @property float|string $top Length in pt, a percentage value, or `auto` * @property array $transform List of transforms - * @property array $transform_origin + * @property array $transform_origin Triplet of `[x, y, z]`, each value being a length in pt, or a percentage value for x and y * @property string $unicode_bidi * @property string $unicode_range * @property string $vertical_align @@ -184,8 +184,42 @@ class Style protected const CSS_INTEGER = "[+-]?\d+"; protected const CSS_NUMBER = "[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?"; protected const CSS_STRING = "" . - '"(?:[^"]|\\\\["])*(?(?:\\\\["]|[^"])*)(?(?:\\\\[']|[^'])*)(? true, + // Comparison Functions + "min" => true, + "max" => true, + "clamp" => true, + // Stepped Value Functions + "round" => true, // Not fully supported + "mod" => true, + "rem" => true, + // Trigonometric Functions + "sin" => true, + "cos" => true, + "tan" => true, + "asin" => true, + "acos" => true, + "atan" => true, + "atan2" => true, + // Exponential Functions + "pow" => true, + "sqrt" => true, + "hypot" => true, + "log" => true, + "exp" => true, + // Sign-Related Functions + "abs" => true, + "sign" => true + ]; /** * https://www.w3.org/TR/css-values-3/#custom-idents @@ -329,7 +363,7 @@ class Style * The order of the sub-properties is relevant for the fallback getter, * which is used in case no specific getter method is defined. * - * @var array + * @var array */ protected static $_props_shorthand = [ "background" => [ @@ -442,7 +476,7 @@ class Style /** * Maps legacy property names to actual property names. * - * @var array + * @var array */ protected static $_props_alias = [ "word_wrap" => "overflow_wrap", @@ -457,78 +491,68 @@ class Style * * @link https://www.w3.org/TR/CSS21/propidx.html * - * @var array + * @var array */ protected static $_defaults = null; /** - * List of inherited properties + * Lookup table for properties that inherit by default. * * @link https://www.w3.org/TR/CSS21/propidx.html * - * @var array - */ - protected static $_inherited = null; - - /** - * Caches method_exists result - * - * @var array - */ - protected static $_methods_cache = []; - - /** - * The stylesheet this style belongs to - * - * @var Stylesheet - */ - protected $_stylesheet; - - /** - * Media queries attached to the style - * - * @var array - */ - protected $_media_queries; - - /** - * Properties set by an `!important` declaration. - * - * @var array - */ - protected $_important_props = []; - - /** - * Specified (or declared) values of the CSS properties. - * - * https://www.w3.org/TR/css-cascade-3/#value-stages - * - * @var array - */ - protected $_props = []; - - /** - * Computed values of the CSS properties. - * - * @var array - */ - protected $_props_computed = []; - - /** - * Used values of the CSS properties. - * - * @var array - */ - protected $_props_used = []; + * @var array + */ + protected static $_inherited = [ + "azimuth" => true, + "background_image_resolution" => true, + "border_collapse" => true, + "border_spacing" => true, + "caption_side" => true, + "color" => true, + "cursor" => true, + "direction" => true, + "elevation" => true, + "empty_cells" => true, + "font_family" => true, + "font_size" => true, + "font_style" => true, + "font_variant" => true, + "font_weight" => true, + "font" => true, + "image_resolution" => true, + "letter_spacing" => true, + "line_height" => true, + "list_style_image" => true, + "list_style_position" => true, + "list_style_type" => true, + "list_style" => true, + "orphans" => true, + "overflow_wrap" => true, + "pitch_range" => true, + "pitch" => true, + "quotes" => true, + "richness" => true, + "speak_header" => true, + "speak_numeral" => true, + "speak_punctuation" => true, + "speak" => true, + "speech_rate" => true, + "stress" => true, + "text_align" => true, + "text_indent" => true, + "text_transform" => true, + "visibility" => true, + "voice_family" => true, + "volume" => true, + "white_space" => true, + "widows" => true, + "word_break" => true, + "word_spacing" => true + ]; /** - * Marks properties with non-final used values that should be cleared on - * style reset. - * - * @var array + * @var array */ - protected $non_final_used = []; - protected static $_dependency_map = [ "border_top_style" => [ "border_top_width" @@ -591,10 +615,102 @@ class Style * Lookup table for dependent properties. Initially computed from the * dependency map. * - * @var array + * @var array */ protected static $_dependent_props = []; + /** + * Caches method_exists result + * + * @var array + */ + protected static $_methods_cache = []; + + /** + * The stylesheet this style belongs to + * + * @var Stylesheet + */ + protected $_stylesheet; + + /** + * Media queries attached to the style + * + * This is a two-dimensional array where the first dimension represents + * the media query grouping (logic-or) and the second dimension the + * media queries within the grouping. + * + * The structure of the actual query element is: + * - media query feature + * - media query value or condition + * - media query operator (e.g., not) + * + * @var array + */ + protected $_media_queries; + + /** + * Properties set by an `!important` declaration. + * + * @var array + */ + protected $_important_props = []; + + /** + * Specified (or declared) values of the CSS properties. + * + * https://www.w3.org/TR/css-cascade-3/#value-stages + * + * @var array + */ + protected $_props = []; + + /** + * Used to track which CSS property were set directly versus + * those set via shorthand property + * + * @var array + */ + protected $_props_specified = []; + + /** + * Computed values of the CSS properties. + * + * @var array + */ + protected $_props_computed = []; + + /** + * Used values of the CSS properties. + * + * @var array + */ + protected $_props_used = []; + + /** + * Marks properties with non-final used values that should be cleared on + * style reset. + * + * @var array + */ + protected $non_final_used = []; + + /** + * Used to track CSS property assignment entry/exit in order to watch + * for circular dependencies. + * + * @var array + */ + protected $_prop_stack = []; + + /** + * Used to track CSS variable resolution entry/exit in order to watch + * for circular dependencies. + * + * @var array + */ + protected $_var_stack = []; + /** * Style of the parent element in document tree. * @@ -789,8 +905,8 @@ public function __construct(Stylesheet $stylesheet, int $origin = Stylesheet::OR // CSS3 $d["opacity"] = 1.0; $d["background_size"] = ["auto", "auto"]; - $d["transform"] = "none"; - $d["transform_origin"] = "50% 50%"; + $d["transform"] = []; + $d["transform_origin"] = ["50%", "50%", 0.0]; // for @font-face $d["src"] = ""; @@ -799,55 +915,6 @@ public function __construct(Stylesheet $stylesheet, int $origin = Stylesheet::OR // vendor-prefixed properties $d["_dompdf_keep"] = ""; - // Properties that inherit by default - self::$_inherited = [ - "azimuth", - "background_image_resolution", - "border_collapse", - "border_spacing", - "caption_side", - "color", - "cursor", - "direction", - "elevation", - "empty_cells", - "font_family", - "font_size", - "font_style", - "font_variant", - "font_weight", - "font", - "image_resolution", - "letter_spacing", - "line_height", - "list_style_image", - "list_style_position", - "list_style_type", - "list_style", - "orphans", - "overflow_wrap", - "pitch_range", - "pitch", - "quotes", - "richness", - "speak_header", - "speak_numeral", - "speak_punctuation", - "speak", - "speech_rate", - "stress", - "text_align", - "text_indent", - "text_transform", - "visibility", - "voice_family", - "volume", - "white_space", - "widows", - "word_break", - "word_spacing", - ]; - // Compute dependent props from dependency map foreach (self::$_dependency_map as $props) { foreach ($props as $prop) { @@ -945,6 +1012,11 @@ public function get_stylesheet(): Stylesheet return $this->_stylesheet; } + public function is_custom_property(string $prop): bool + { + return \substr($prop, 0, 2) === "--"; + } + public function is_absolute(): bool { $position = $this->__get("position"); @@ -1023,9 +1095,15 @@ protected function single_length_in_pt(string $l, float $ref_size = 0, ?float $f } $number = self::CSS_NUMBER; - $pattern = "/^($number)(.*)?$/"; + $pattern = "/^($number)([a-zA-Z%]*)?$/"; if (!preg_match($pattern, $l, $matches)) { + $ident = self::CSS_IDENTIFIER; + $pattern = "/^($ident)\(.*\)$/i"; + if (preg_match($pattern, $l)) { + $value = $this->evaluate_func($this->parse_func($l), $ref_size, $font_size); + return $cache[$key] = $value; + } return null; } @@ -1100,6 +1178,249 @@ protected function single_length_in_pt(string $l, float $ref_size = 0, ?float $f return $cache[$key] = $value; } + /** + * Shunting-yard Algorithm + * @param string $expr infix expression + * @return array + */ + private function parse_func(string $expr): array + { + if (substr_count($expr, '(') !== substr_count($expr, ')')) { + return []; + } + + $expr = str_replace(['(', ')', '*', '/', ','], [' ( ', ' ) ', ' * ', ' / ', ' , '], $expr); + $expr = trim(preg_replace('/\s+/', ' ', $expr)); + + if ($expr === '') { + return []; + } + + $precedence = ['*' => 3, '/' => 3, '+' => 2, '-' => 2, ',' => 1]; + + $opStack = []; + $queue = []; + + $parts = explode(' ', $expr); + + foreach ($parts as $part) { + if ($part === '(') { + $opStack[] = $part; + } elseif (\array_key_exists(strtolower($part), self::CSS_MATH_FUNCTIONS)) { + $opStack[] = strtolower($part); + } elseif ($part === ')') { + while (\count($opStack) > 0 && end($opStack) !== '(' && !\array_key_exists(end($opStack), self::CSS_MATH_FUNCTIONS)) { + $queue[] = array_pop($opStack); + } + if (end($opStack) === '(') { + array_pop($opStack); + } + if (\count($opStack) > 0 && \array_key_exists(end($opStack), self::CSS_MATH_FUNCTIONS)) { + $queue[] = array_pop($opStack); + } + } elseif (\array_key_exists($part, $precedence)) { + while (\count($opStack) > 0 && end($opStack) !== '(' && $precedence[end($opStack)] >= $precedence[$part]) { + $queue[] = array_pop($opStack); + } + $opStack[] = $part; + } else { + $queue[] = $part; + } + } + + while (\count($opStack) > 0) { + $queue[] = array_pop($opStack); + } + + return $queue; + } + + /** + * Reverse Polish Notation + * @param array $rpn + * @param float $ref_size + * @param float|null $font_size + * @return float|null + */ + private function evaluate_func(array $rpn, float $ref_size = 0, ?float $font_size = null): ?float + { + if (\count($rpn) === 0) { + return null; + } + + $ops = ['*', '/', '+', '-', ',']; + + $stack = []; + + foreach ($rpn as $part) { + if (\array_key_exists($part, self::CSS_MATH_FUNCTIONS)) { + $argv = array_pop($stack); + if (!is_array($argv)) { + $argv = [$argv]; + } + $argc = \count($argv); + switch ($part) { + case 'abs': + case 'acos': + case 'asin': + case 'atan': + case 'cos': + case 'exp': + case 'sin': + case 'sqrt': + case 'tan': + if ($argc !== 1) { + return null; + } + $stack[] = call_user_func_array($part, $argv); + break; + case 'atan2': + case 'hypot': + case 'pow': + if ($argc !== 2) { + return null; + } + $stack[] = call_user_func_array($part, $argv); + break; + case 'log': + if ($argc === 1) { + $stack[] = log($argv[0]); + } elseif ($argc === 2) { + $stack[] = log($argv[0], $argv[1]); + } else { + return null; + } + break; + case 'max': + $stack[] = max($argv); + break; + case 'min': + $stack[] = min($argv); + break; + case 'mod': + if ($argc !== 2 || $argv[1] === 0.0) { + return null; + } + if ($argv[1] > 0) { + $stack[] = $argv[0] - floor($argv[0] / $argv[1]) * $argv[1]; + } else { + $stack[] = $argv[0] - ceil($argv[0] * -1 / $argv[1]) * $argv[1] * -1 ; + } + break; + case 'rem': + if ($argc !== 2 || $argv[1] === 0.0) { + return null; + } + $stack[] = $argv[0] - (intval($argv[0] / $argv[1]) * $argv[1]); + break; + case 'round': + if ($argc !== 2 || $argv[1] === 0.0) { + return null; + } + if ($argv[0] >= 0) { + $stack[] = round($argv[0] / $argv[1], 0, PHP_ROUND_HALF_UP) * $argv[1]; + } else { + $stack[] = round($argv[0] / $argv[1], 0, PHP_ROUND_HALF_DOWN) * $argv[1]; + } + break; + case 'calc': + if ($argc !== 1) { + return null; + } + $stack[] = $argv[0]; + break; + case 'clamp': + if ($argc !== 3) { + return null; + } + $stack[] = max($argv[0], min($argv[1], $argv[2])); + break; + case 'sign': + if ($argc !== 1) { + return null; + } + $stack[] = $argv[0] == 0 ? 0.0 : ($argv[0] / abs($argv[0])); + break; + default: + return null; + } + } elseif (\in_array($part, $ops, true)) { + $rightValue = array_pop($stack); + $leftValue = array_pop($stack); + switch ($part) { + case '*': + $stack[] = $leftValue * $rightValue; + break; + case '/': + if ($rightValue === 0.0) { + return null; + } + $stack[] = $leftValue / $rightValue; + break; + case '+': + $stack[] = $leftValue + $rightValue; + break; + case '-': + $stack[] = $leftValue - $rightValue; + break; + case ',': + if (is_array($leftValue)) { + $leftValue[] = $rightValue; + $stack[] = $leftValue; + } else { + $stack[] = [$leftValue, $rightValue]; + } + break; + } + } else { + $val = $this->single_length_in_pt($part, $ref_size, $font_size); + if ($val === null) { + return null; + } + $stack[] = $val; + } + } + + if (\count($stack) > 1) { + return null; + } + + return floatval(end($stack)); + } + + /** + * Resolves the actual values for used CSS custom properties. + * + * This function receives the whole content of the var() function, which + * can also include a fallback value. + */ + private function parse_var($matches) { + $variable = is_array($matches) ? $matches[1] : $matches; + + if (\in_array($variable, $this->_var_stack, true)) { + return null; + } + array_push($this->_var_stack, $variable); + + // Split property name and an optional fallback value. + [$custom_prop, $fallback] = explode(',', $variable, 2) + ['', '']; + $fallback = trim($fallback); + + // Try to retrieve the custom property value, or use the fallback value + // if the value could not be resolved. + $value = $this->computed($custom_prop) ?? $fallback; + + // If the resolved value also has vars in it, resolve again. + $pattern = self::CSS_VAR; + $value = preg_replace_callback( + "/$pattern/", + [$this, "parse_var"], + $value); + + array_pop($this->_var_stack); + return $value ?: null; + } + /** * Resolve inherited property values using the provided parent style or the * default values, in case no parent style exists. @@ -1118,20 +1439,27 @@ public function inherit(?Style $parent = null): void unset($this->_props_used["font_size"]); if ($parent) { - foreach (self::$_inherited as $prop) { - // For properties that inherit by default: When the cascade did - // not result in a value, inherit the parent value. Inheritance - // is handled via the specific sub-properties for shorthands - if (isset($this->_props[$prop]) || isset(self::$_props_shorthand[$prop])) { - continue; - } - - if (isset($parent->_props[$prop])) { + // For properties that inherit by default: When the cascade did + // not result in a value, inherit the parent value. Inheritance + // is handled via the specific sub-properties for shorthands. Custom + // properties (variables) are selected by the -- prefix. + foreach ($parent->_props as $prop => $val) { + if ( + !isset($this->_props[$prop]) + && ( + isset(self::$_inherited[$prop]) + || $this->is_custom_property($prop) + ) + ) { $parent_val = $parent->computed($prop); - $this->_props[$prop] = $parent_val; - $this->_props_computed[$prop] = $parent_val; - $this->_props_used[$prop] = null; + if ($this->is_custom_property($prop)) { + $this->set_prop($prop, $parent_val); + } else { + $this->_props[$prop] = $parent_val; + $this->_props_computed[$prop] = $parent_val; + $this->_props_used[$prop] = null; + } } } } @@ -1141,14 +1469,22 @@ public function inherit(?Style $parent = null): void if ($parent && isset($parent->_props[$prop])) { $parent_val = $parent->computed($prop); - $this->_props[$prop] = $parent_val; - $this->_props_computed[$prop] = $parent_val; - $this->_props_used[$prop] = null; + if ($this->is_custom_property($prop)) { + $this->set_prop($prop, $parent_val); + } else { + $this->_props[$prop] = $parent_val; + $this->_props_computed[$prop] = $parent_val; + $this->_props_used[$prop] = null; + } } else { - // Parent prop not set, use default - $this->_props[$prop] = self::$_defaults[$prop]; - unset($this->_props_computed[$prop]); - unset($this->_props_used[$prop]); + if ($this->is_custom_property($prop)) { + $this->set_prop($prop, "unset"); + } else { + // Parent prop not set, use default + $this->_props[$prop] = self::$_defaults[$prop]; + unset($this->_props_computed[$prop]); + unset($this->_props_used[$prop]); + } } } } @@ -1173,7 +1509,11 @@ public function merge(Style $style): void $this->_important_props[$prop] = true; } - $this->_props[$prop] = $val; + if ($this->is_custom_property($prop)) { + $this->set_prop($prop, $val, $important); + } else { + $this->_props[$prop] = $val; + } // Copy an existing computed value only for non-dependent // properties; otherwise it may be invalid for the current style @@ -1186,6 +1526,18 @@ public function merge(Style $style): void unset($this->_props_computed[$prop]); unset($this->_props_used[$prop]); } + + if (\array_key_exists($prop, $style->_props_specified)) { + $this->_props_specified[$prop] = true; + } + } + + // re-evalutate CSS variables + foreach (array_keys($this->_props) as $prop) { + if (!$this->is_custom_property($prop)) { + continue; + } + $this->set_prop($prop, $this->_props[$prop], isset($this->_important_props[$prop])); } } @@ -1241,18 +1593,23 @@ protected function clear_cache(string $prop): void */ public function set_prop(string $prop, $val, bool $important = false, bool $clear_dependencies = true): void { - $prop = str_replace("-", "_", $prop); + // Skip some checks for CSS custom properties. + if (!$this->is_custom_property($prop)) { - // Legacy property aliases - if (isset(self::$_props_alias[$prop])) { - $prop = self::$_props_alias[$prop]; - } + $prop = str_replace("-", "_", $prop); - if (!isset(self::$_defaults[$prop])) { - global $_dompdf_warnings; - $_dompdf_warnings[] = "'$prop' is not a recognized CSS property."; - return; + // Legacy property aliases + if (isset(self::$_props_alias[$prop])) { + $prop = self::$_props_alias[$prop]; + } + + if (!isset(self::$_defaults[$prop])) { + global $_dompdf_warnings; + $_dompdf_warnings[] = "'$prop' is not a recognized CSS property."; + return; + } } + $this->_props_specified[$prop] = true; // Trim declarations unconditionally, but only lower-case for comparison // with the general keywords. Properties must handle case-insensitive @@ -1276,6 +1633,29 @@ public function set_prop(string $prop, $val, bool $important = false, bool $clea } else { $method = "_set_$prop"; + // Resolve the CSS custom property value(s). + $pattern = self::CSS_VAR; + + // Always set the specified value for properties that use CSS variables + // so that an invalid initial value does not prevent re-computation later. + $this->_props[$prop] = $val; + + //TODO: we shouldn't need to parse this twice + preg_match_all("/$pattern/", $val, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + if ($this->parse_var($match) === null) { + // unset specified as for specific prop under expectation it will be overridden + foreach (self::$_props_shorthand[$prop] as $sub_prop) { + unset($this->_props_specified[$sub_prop]); + } + return; + } + } + $val = preg_replace_callback( + "/$pattern/", + [$this, "parse_var"], + $val); + if (!isset(self::$_methods_cache[$method])) { self::$_methods_cache[$method] = method_exists($this, $method); } @@ -1292,6 +1672,7 @@ public function set_prop(string $prop, $val, bool $important = false, bool $clea foreach (self::$_props_shorthand[$prop] as $sub_prop) { $sub_val = $values[$sub_prop] ?? self::$_defaults[$sub_prop]; $this->set_prop($sub_prop, $sub_val, $important, $clear_dependencies); + unset($this->_props_specified[$sub_prop]); } } } @@ -1316,16 +1697,20 @@ public function set_prop(string $prop, $val, bool $important = false, bool $clea // https://www.w3.org/TR/css-cascade-3/#inherit-initial if ($val === "unset") { - $val = \in_array($prop, self::$_inherited, true) - ? "inherit" - : "initial"; + $val = isset(self::$_inherited[$prop]) || $this->is_custom_property($prop) ? "inherit" : "initial"; } // https://www.w3.org/TR/css-cascade-3/#valdef-all-initial - if ($val === "initial") { + if ($val === "initial" && !$this->is_custom_property($prop)) { $val = self::$_defaults[$prop]; } + // Always set the specified value for properties that use CSS variables + // so that an invalid initial value does not prevent re-computation later. + if (\is_string($val) && \preg_match("/" . self::CSS_VAR . "/", $val)) { + $this->_props[$prop] = $val; + } + $computed = $this->compute_prop($prop, $val); // Skip invalid declarations @@ -1337,6 +1722,27 @@ public function set_prop(string $prop, $val, bool $important = false, bool $clea $this->_props_computed[$prop] = $computed; $this->_props_used[$prop] = null; + //TODO: this should be a directed dependency map + if ($this->is_custom_property($prop) && !\in_array($prop, $this->_prop_stack, true)) { + array_push($this->_prop_stack, $prop); + $specified_props = array_filter($this->_props, function($key) { + return \array_key_exists($key, $this->_props_specified); + }, ARRAY_FILTER_USE_KEY); // copy existing props filtered by those set explicitly before parsing vars + foreach ($specified_props as $specified_prop => $specified_value) { + if (!$this->is_custom_property($specified_prop) || strpos($specified_value, "var($prop") !== false) { + $this->set_prop($specified_prop, $specified_value, isset($this->_important_props[$specified_prop]), true); + if (isset(self::$_props_shorthand[$specified_prop])) { + foreach (self::$_props_shorthand[$specified_prop] as $sub_prop) { + if (\array_key_exists($sub_prop, $specified_props)) { + $this->set_prop($sub_prop, $specified_props[$sub_prop], isset($this->_important_props[$sub_prop]), true); + } + } + } + } + } + array_pop($this->_prop_stack); + } + if ($clear_dependencies) { // Clear the computed values of any dependent properties, so // they can be re-computed @@ -1367,7 +1773,7 @@ public function get_specified(string $prop) $prop = self::$_props_alias[$prop]; } - if (!isset(self::$_defaults[$prop])) { + if (!isset(self::$_defaults[$prop]) && !$this->is_custom_property($prop)) { throw new Exception("'$prop' is not a recognized CSS property."); } @@ -1396,7 +1802,7 @@ public function __set(string $prop, $val) $prop = self::$_props_alias[$prop]; } - if (!isset(self::$_defaults[$prop])) { + if (!isset(self::$_defaults[$prop]) && !$this->is_custom_property($prop)) { throw new Exception("'$prop' is not a recognized CSS property."); } @@ -1463,7 +1869,7 @@ public function __get(string $prop) $prop = self::$_props_alias[$prop]; } - if (!isset(self::$_defaults[$prop])) { + if (!isset(self::$_defaults[$prop]) && !$this->is_custom_property($prop)) { throw new Exception("'$prop' is not a recognized CSS property."); } @@ -1511,7 +1917,7 @@ protected function compute_prop(string $prop, $val) // During style merge, the parent style is not available yet, so // temporarily use the initial value for `inherit` properties. The // keyword is properly resolved during inheritance - if ($val === "inherit") { + if ($val === "inherit" && !$this->is_custom_property($prop)) { $val = self::$_defaults[$prop]; } @@ -1520,6 +1926,13 @@ protected function compute_prop(string $prop, $val) return $val; } + // Resolve the CSS custom property value(s). + $pattern = self::CSS_VAR; + $val = preg_replace_callback( + "/$pattern/", + [$this, "parse_var"], + $val); + $method = "_compute_$prop"; if (!isset(self::$_methods_cache[$method])) { @@ -1545,9 +1958,19 @@ protected function compute_prop(string $prop, $val) protected function computed(string $prop) { if (!\array_key_exists($prop, $this->_props_computed)) { + if (!\array_key_exists($prop, $this->_props) && $this->is_custom_property($prop)) { + return null; + } $val = $this->_props[$prop] ?? self::$_defaults[$prop]; $computed = $this->compute_prop($prop, $val); + if ($computed === null) { + if ($this->is_custom_property($prop)) { + return null; + } + $computed = $this->compute_prop($prop, self::$_defaults[$prop]); + } + $this->_props_computed[$prop] = $computed; } @@ -1597,69 +2020,69 @@ public function get_font_family_raw(): string return trim($this->_props["font_family"], " \t\n\r\x0B\"'"); } + /** + * @return string[] + */ + public function get_font_family_computed(): array + { + return $this->computed("font_family"); + } + /** * Getter for the `font-family` CSS property. * * Uses the {@link FontMetrics} class to resolve the font family into an * actual font file. * - * @param string $computed + * @param string[] $computed * @return string + * * @throws Exception * * @link https://www.w3.org/TR/CSS21/fonts.html#propdef-font-family */ protected function _get_font_family($computed): string { - //TODO: we should be using the calculated prop rather than perform the entire family parsing operation again - - $fontMetrics = $this->getFontMetrics(); - $DEBUGCSS = $this->_stylesheet->get_dompdf()->getOptions()->getDebugCss(); + // TODO: It probably makes sense to perform the font selection outside + // the Style class completely. It is now done primarily in + // `FrameDecorator\Text::apply_font_mapping` // Select the appropriate font. First determine the subtype, then check // the specified font-families for a candidate. - // Resolve font-weight + $fontMetrics = $this->getFontMetrics(); $weight = $this->__get("font_weight"); - $font_style = $this->__get("font_style"); - $subtype = $fontMetrics->getType($weight . ' ' . $font_style); - - $families = preg_split("/\s*,\s*/", $computed); - - $font = null; - foreach ($families as $family) { - //remove leading and trailing string delimiters, e.g. on font names with spaces; - //remove leading and trailing whitespace - $family = trim($family, " \t\n\r\x0B\"'"); - if ($DEBUGCSS) { - print '(' . $family . ')'; - } + $fontStyle = $this->__get("font_style"); + $subtype = $fontMetrics->getType($weight . ' ' . $fontStyle); + + foreach ($computed as $family) { $font = $fontMetrics->getFont($family, $subtype); - if ($font) { - if ($DEBUGCSS) { - print "
[get_font_family:";
-                    print '(' . $computed . '.' . $font_style . '.' . $weight . '.' . $subtype . ')';
-                    print '(' . $font . ")get_font_family]\n
"; - } + if ($font !== null) { return $font; } } - $family = null; - if ($DEBUGCSS) { - print '(default)'; - } - $font = $fontMetrics->getFont($family, $subtype); + $font = $fontMetrics->getFont(null, $subtype); - if ($font) { - if ($DEBUGCSS) { - print '(' . $font . ")get_font_family]\n"; - } + if ($font !== null) { return $font; } - throw new Exception("Unable to find a suitable font replacement for: '" . $computed . "'"); + $specified = implode(", ", $computed); + throw new Exception("Unable to find a suitable font replacement for: '$specified'"); + } + + /** + * @param float $computed + * @return float + * + * @link https://www.w3.org/TR/CSS21/fonts.html#propdef-font-size + */ + protected function _get_font_size($computed) + { + // Computed value may be negative when specified via `calc()` + return max($computed, 0.0); } /** @@ -1706,7 +2129,8 @@ protected function _get_line_height($computed) { // Lengths have been computed to float, number values to string if (\is_float($computed)) { - return $computed; + // Computed value may be negative when specified via `calc()` + return max($computed, 0.0); } $font_size = $this->__get("font_size"); @@ -2112,12 +2536,12 @@ protected function _get_quotes($computed) protected function parse_string(string $string): string { // Strip string quotes and escapes - $string = preg_replace('/^[\"\']|[\"\']$/', "", $string); - $string = str_replace(["\\\n", '\\"', "\\'"], ["", '"', "'"], $string); + $string = preg_replace('/^["\']|["\']$/', "", $string); + $string = preg_replace("/\\\\([^0-9a-fA-F])/", "\\1", $string); - // Convert escaped hex characters into ascii characters (e.g. \A => newline) + // Convert escaped hex characters (e.g. \A => newline) return preg_replace_callback( - "/\\\\([0-9a-fA-F]{0,6})/", + "/\\\\([0-9a-fA-F]{1,6})/", function ($matches) { return Helpers::unichr(hexdec($matches[1])); }, $string ) ?? ""; @@ -2137,13 +2561,14 @@ protected function parse_property_value(string $value): array $number = self::CSS_NUMBER; $pattern = "/\n" . - "\s* ($string) |\n" . // String - "\s* ($ident \\([^)]*\\) ) |\n" . // Functional - "\s* ($ident) |\n" . // Keyword - "\s* (\#[0-9a-fA-F]*) |\n" . // Hex value - "\s* ($number [a-zA-Z%]*) |\n" . // Number (+ unit/percentage) - "\s* ([\/,;]) \n" . // Delimiter - "/Sx"; + "\s* (?$string) |\n" . // String + "\s* (url \( (?> (\\\\[\"'()] | [^\"'()])* ) (? \g | [^\"'()]+ ) | (?-2))* \)) ) |\n" . // Function (with balanced parentheses) + "\s* ($ident) |\n" . // Keyword + "\s* (\#[0-9a-fA-F]*) |\n" . // Hex value + "\s* ($number [a-zA-Z%]*) |\n" . // Number (+ unit/percentage) + "\s* ([\/,;]) \n" . // Delimiter + "/iSx"; if (!preg_match_all($pattern, $value, $matches)) { return []; @@ -2191,6 +2616,18 @@ protected function compute_integer(string $val): ?int : null; } + /** + * @param string $val + * @return float|null + */ + protected function compute_number(string $val): ?float + { + $number = self::CSS_NUMBER; + return preg_match("/^$number$/", $val) + ? (float) $val + : null; + } + /** * @param string $val * @return float|null @@ -2209,7 +2646,15 @@ protected function compute_length(string $val): ?float protected function compute_length_positive(string $val): ?float { $computed = $this->compute_length($val); - return $computed !== null && $computed >= 0 ? $computed : null; + + // Negative non-`calc` values are invalid + if ($computed === null + || ($computed < 0 && !preg_match("/^-?[_a-zA-Z]/", $val)) + ) { + return null; + } + + return $computed; } /** @@ -2240,7 +2685,10 @@ protected function compute_length_percentage_positive(string $val) // are valid $computed = $this->single_length_in_pt($val, 12); - if ($computed === null || $computed < 0) { + // Negative non-`calc` values are invalid + if ($computed === null + || ($computed < 0 && !preg_match("/^-?[_a-zA-Z]/", $val)) + ) { return null; } @@ -2294,6 +2742,104 @@ protected function compute_border_style(string $val): ?string return \in_array($val, self::BORDER_STYLES, true) ? $val : null; } + /** + * @param string $val + * @return float|null + * + * @link https://www.w3.org/TR/css3-values/#angles + */ + protected function compute_angle_or_zero(string $val): ?float + { + $number = self::CSS_NUMBER; + $pattern = "/^($number)(deg|grad|rad|turn)?$/i"; + + if (!preg_match($pattern, $val, $matches)) { + return null; + } + + $v = (float) $matches[1]; + $unit = strtolower($matches[2] ?? ""); + + switch ($unit) { + case "deg": + return $v; + case "grad": + return $v * 0.9; + case "rad": + return rad2deg($v); + case "turn": + return $v * 360; + default: + return $v === 0.0 ? $v : null; + } + } + + /** + * Common computation logic for `background-position` and `transform-origin`. + * + * @param string $v1 + * @param string $v2 + * + * @return (float|string|null)[] + */ + protected function computeBackgroundPositionTransformOrigin(string $v1, string $v2): array + { + $x = null; + $y = null; + + switch ($v1) { + case "left": + $x = 0.0; + break; + case "right": + $x = "100%"; + break; + case "top": + $y = 0.0; + break; + case "bottom": + $y = "100%"; + break; + case "center": + if ($v2 === "left" || $v2 === "right") { + $y = "50%"; + } else { + $x = "50%"; + } + break; + default: + $x = $this->compute_length_percentage($v1); + break; + } + + switch ($v2) { + case "left": + $x = 0.0; + break; + case "right": + $x = "100%"; + break; + case "top": + $y = 0.0; + break; + case "bottom": + $y = "100%"; + break; + case "center": + if ($v1 === "top" || $v1 === "bottom") { + $x = "50%"; + } else { + $y = "50%"; + } + break; + default: + $y = $this->compute_length_percentage($v2); + break; + } + + return [$x, $y]; + } + /** * @link https://www.w3.org/TR/css-lists-3/#typedef-counter-name */ @@ -2423,7 +2969,7 @@ protected function _compute_background_image(string $val) if ($parsed_val === "none") { return "none"; } else { - return "url($parsed_val)"; + return "url(\"" . str_replace("\"", "\\\"", $parsed_val) . "\")"; } } @@ -2453,92 +2999,17 @@ protected function _compute_background_attachment(string $val) protected function _compute_background_position(string $val) { $val = strtolower($val); - $parts = preg_split("/\s+/", $val); + $parts = $this->parse_property_value($val); $count = \count($parts); - $x = null; - $y = null; - - if ($count === 1) { - switch ($parts[0]) { - case "left": - $x = 0.0; - $y = "50%"; - break; - case "right": - $x = "100%"; - $y = "50%"; - break; - case "top": - $x = "50%"; - $y = 0.0; - break; - case "bottom": - $x = "50%"; - $y = "100%"; - break; - case "center": - $x = "50%"; - $y = "50%"; - break; - default: - $x = $this->compute_length_percentage($parts[0]); - $y = "50%"; - break; - } - } elseif ($count === 2) { - switch ($parts[0]) { - case "left": - $x = 0.0; - break; - case "right": - $x = "100%"; - break; - case "top": - $y = 0.0; - break; - case "bottom": - $y = "100%"; - break; - case "center": - if ($parts[1] === "left" || $parts[1] === "right") { - $y = "50%"; - } else { - $x = "50%"; - } - break; - default: - $x = $this->compute_length_percentage($parts[0]); - break; - } - switch ($parts[1]) { - case "left": - $x = 0.0; - break; - case "right": - $x = "100%"; - break; - case "top": - $y = 0.0; - break; - case "bottom": - $y = "100%"; - break; - case "center": - if ($parts[0] === "top" || $parts[0] === "bottom") { - $x = "50%"; - } else { - $y = "50%"; - } - break; - default: - $y = $this->compute_length_percentage($parts[1]); - break; - } - } else { + if ($count === 0 || $count > 2) { return null; } + $v1 = $parts[0]; + $v2 = $parts[1] ?? "center"; + [$x, $y] = $this->computeBackgroundPositionTransformOrigin($v1, $v2); + if ($x === null || $y === null) { return null; } @@ -2564,9 +3035,10 @@ protected function _compute_background_size(string $val) return $val; } - $parts = preg_split("/\s+/", $val); + $parts = $this->parse_property_value($val); + $count = \count($parts); - if (\count($parts) > 2) { + if ($count === 0 || $count > 2) { return null; } @@ -2636,17 +3108,30 @@ protected function _set_background(string $value): array return $props; } + /** + * @link https://www.w3.org/TR/CSS21/fonts.html#propdef-font-family + */ + protected function _compute_font_family(string $val) + { + return array_map( + function ($name) { + return trim($name, " '\""); + }, + preg_split("/\s*,\s*/", $val) + ); + } + /** * @link https://www.w3.org/TR/CSS21/fonts.html#propdef-font-size */ - protected function _compute_font_size(string $size) + protected function _compute_font_size(string $val) { - $size = strtolower($size); - $parent_font_size = isset($this->parent_style) + $val = strtolower($val); + $parentFontSize = isset($this->parent_style) ? $this->parent_style->__get("font_size") : self::$default_font_size; - switch ($size) { + switch ($val) { case "xx-small": case "x-small": case "small": @@ -2654,23 +3139,30 @@ protected function _compute_font_size(string $size) case "large": case "x-large": case "xx-large": - $fs = self::$default_font_size * self::$font_size_keywords[$size]; + $computed = self::$default_font_size * self::$font_size_keywords[$val]; break; case "smaller": - $fs = 8 / 9 * $parent_font_size; + $computed = 8 / 9 * $parentFontSize; break; case "larger": - $fs = 6 / 5 * $parent_font_size; + $computed = 6 / 5 * $parentFontSize; break; default: - $fs = $this->single_length_in_pt($size, $parent_font_size, $parent_font_size); + $computed = $this->single_length_in_pt($val, $parentFontSize, $parentFontSize); + + // Negative non-`calc` values are invalid + if ($computed === null + || ($computed < 0 && !preg_match("/^-?[_a-zA-Z]/", $val)) + ) { + return null; + } break; } - return $fs; + return $computed; } /** @@ -2741,6 +3233,14 @@ protected function _compute_font_weight(string $val) } } + /** + * @link https://www.w3.org/TR/css-fonts-4/#src-desc + */ + protected function _compute_src(string $val) + { + return $val; + } + /** * Handle the `font` shorthand property. * @@ -2893,7 +3393,15 @@ protected function _compute_line_height(string $val) $font_size = $this->__get("font_size"); $computed = $this->single_length_in_pt($val, $font_size); - return $computed !== null && $computed >= 0 ? $computed : null; + + // Negative non-`calc` values are invalid + if ($computed === null + || ($computed < 0 && !preg_match("/^-?[_a-zA-Z]/", $val)) + ) { + return null; + } + + return $computed; } /** @@ -3162,7 +3670,7 @@ protected function _compute_padding_left(string $val) * @param string $value `width || style || color` * @param string[] $styles The list of border styles to accept. * - * @return array Array of `[width, style, color]`, or `null` if the declaration is invalid. + * @return string[]|null Array of `[width, style, color]`, or `null` if the declaration is invalid. */ protected function parse_border_side(string $value, array $styles = self::BORDER_STYLES): ?array { @@ -3406,9 +3914,10 @@ protected function _compute_outline_offset(string $val) protected function _compute_border_spacing(string $val) { $val = strtolower($val); - $parts = preg_split("/\s+/", $val); + $parts = $this->parse_property_value($val); + $count = \count($parts); - if (\count($parts) > 2) { + if ($count === 0 || $count > 2) { return null; } @@ -3434,7 +3943,7 @@ protected function _compute_list_style_image(string $val) if ($parsed_val === "none") { return "none"; } else { - return "url($parsed_val)"; + return "url(\"" . str_replace("\"", "\\\"", $parsed_val) . "\")"; } } @@ -3802,159 +4311,185 @@ protected function _compute_size(string $val) } /** - * @param string $computed - * @return array - * * @link https://www.w3.org/TR/css-transforms-1/#transform-property */ - protected function _get_transform($computed) + protected function _compute_transform(string $val) { - //TODO: should be handled in setter (lengths set to absolute) - - $number = "\s*([^,\s]+)\s*"; - $tr_value = "\s*([^,\s]+)\s*"; - $angle = "\s*([^,\s]+(?:deg|rad)?)\s*"; + $val = strtolower($val); - if (!preg_match_all("/[a-z]+\([^\)]+\)/i", $computed, $parts, PREG_SET_ORDER)) { + if ($val === "none") { return []; } - $functions = [ - //"matrix" => "\($number,$number,$number,$number,$number,$number\)", + $parts = $this->parse_property_value($val); + $transforms = []; - "translate" => "\($tr_value(?:,$tr_value)?\)", - "translateX" => "\($tr_value\)", - "translateY" => "\($tr_value\)", + if ($parts === []) { + return null; + } - "scale" => "\($number(?:,$number)?\)", - "scaleX" => "\($number\)", - "scaleY" => "\($number\)", + foreach ($parts as $part) { + if (!preg_match("/^([a-z]+)\((.+)\)$/s", $part, $matches)) { + return null; + } - "rotate" => "\($angle\)", + $name = $matches[1]; + $arguments = trim($matches[2]); + $values = $this->parse_property_value($arguments); + $values = array_values(array_filter($values, function ($v) { + return $v !== ","; + })); + $count = \count($values); - "skew" => "\($angle(?:,$angle)?\)", - "skewX" => "\($angle\)", - "skewY" => "\($angle\)", - ]; + if ($count === 0) { + return null; + } - $transforms = []; + switch ($name) { + // case "matrix": + // if ($count !== 6) { + // return null; + // } - foreach ($parts as $part) { - $t = $part[0]; - - foreach ($functions as $name => $pattern) { - if (preg_match("/$name\s*$pattern/i", $t, $matches)) { - $values = \array_slice($matches, 1); - - switch ($name) { - // units - case "rotate": - case "skew": - case "skewX": - case "skewY": - - foreach ($values as $i => $value) { - if (strpos($value, "rad")) { - $values[$i] = rad2deg((float) $value); - } else { - $values[$i] = (float) $value; - } - } + // $values = array_map([$this, "compute_number"], $values); + // break; - switch ($name) { - case "skew": - if (!isset($values[1])) { - $values[1] = 0; - } - break; - case "skewX": - $name = "skew"; - $values = [$values[0], 0]; - break; - case "skewY": - $name = "skew"; - $values = [0, $values[0]]; - break; - } - break; + // units + case "translate": + if ($count > 2) { + return null; + } - // units - case "translate": - $values[0] = $this->length_in_pt($values[0], (float)$this->length_in_pt($this->width)); + $values = [ + $this->compute_length_percentage($values[0]), + isset($values[1]) ? $this->compute_length_percentage($values[1]) : 0.0 + ]; + break; - if (isset($values[1])) { - $values[1] = $this->length_in_pt($values[1], (float)$this->length_in_pt($this->height)); - } else { - $values[1] = 0; - } - break; - - case "translateX": - $name = "translate"; - $values = [$this->length_in_pt($values[0], (float)$this->length_in_pt($this->width)), 0]; - break; - - case "translateY": - $name = "translate"; - $values = [0, $this->length_in_pt($values[0], (float)$this->length_in_pt($this->height))]; - break; - - // units - case "scale": - if (!isset($values[1])) { - $values[1] = $values[0]; - } - break; + case "translatex": + if ($count > 1) { + return null; + } + + $name = "translate"; + $values = [$this->compute_length_percentage($values[0]), 0.0]; + break; + + case "translatey": + if ($count > 1) { + return null; + } + + $name = "translate"; + $values = [0.0, $this->compute_length_percentage($values[0])]; + break; - case "scaleX": - $name = "scale"; - $values = [$values[0], 1.0]; - break; + // units + case "scale": + if ($count > 2) { + return null; + } + + $v0 = $this->compute_number($values[0]); + $v1 = isset($values[1]) ? $this->compute_number($values[1]) : $v0; + $values = [$v0, $v1]; + break; + + case "scalex": + if ($count > 1) { + return null; + } + + $name = "scale"; + $values = [$this->compute_number($values[0]), 1.0]; + break; + + case "scaley": + if ($count > 1) { + return null; + } + + $name = "scale"; + $values = [1.0, $this->compute_number($values[0])]; + break; + + // units + case "rotate": + if ($count > 1) { + return null; + } + + $values = [$this->compute_angle_or_zero($values[0])]; + break; - case "scaleY": - $name = "scale"; - $values = [1.0, $values[0]]; - break; + case "skew": + if ($count > 2) { + return null; } - $transforms[] = [ - $name, - $values, + $values = [ + $this->compute_angle_or_zero($values[0]), + isset($values[1]) ? $this->compute_angle_or_zero($values[1]) : 0.0 ]; + break; + + case "skewx": + if ($count > 1) { + return null; + } + + $name = "skew"; + $values = [$this->compute_angle_or_zero($values[0]), 0.0]; + break; + + case "skewy": + if ($count > 1) { + return null; + } + + $name = "skew"; + $values = [0.0, $this->compute_angle_or_zero($values[0])]; + break; + + default: + return null; + } + + foreach ($values as $v) { + if ($v === null) { + return null; } } + + $transforms[] = [$name, $values]; } return $transforms; } /** - * @param string $computed - * @return array - * * @link https://www.w3.org/TR/css-transforms-1/#transform-origin-property */ - protected function _get_transform_origin($computed) + protected function _compute_transform_origin(string $val) { - //TODO: should be handled in setter + $val = strtolower($val); + $parts = $this->parse_property_value($val); + $count = \count($parts); - $values = preg_split("/\s+/", $computed); + if ($count === 0 || $count > 3) { + return null; + } - $values = array_map(function ($value) { - if (\in_array($value, ["top", "left"], true)) { - return 0; - } elseif (\in_array($value, ["bottom", "right"], true)) { - return "100%"; - } else { - return $value; - } - }, $values); + $v1 = $parts[0]; + $v2 = $parts[1] ?? "center"; + [$x, $y] = $this->computeBackgroundPositionTransformOrigin($v1, $v2); + $z = $count === 3 ? $this->compute_length($parts[2]) : 0.0; - if (!isset($values[1])) { - $values[1] = $values[0]; + if ($x === null || $y === null || $z === null) { + return null; } - return $values; + return [$x, $y, $z]; } /** diff --git a/src/Css/Stylesheet.php b/src/Css/Stylesheet.php index 852fc5e16..4f2908410 100644 --- a/src/Css/Stylesheet.php +++ b/src/Css/Stylesheet.php @@ -54,6 +54,34 @@ class Stylesheet */ const ORIG_AUTHOR = 3; + /** + * RegEx pattern representing a CSS string + * + * @var string + */ + const PATTERN_CSS_STRING = '(?(?[\'"])(?.*?)(?url\(\s*(?[\'"]?)(?.*?)(?(CSS_URL_FN_QUOTE)(?local\(\s*(?[\'"]?)(?.*?)(?(CSS_LOCAL_FN_QUOTE)(?(?:(?:(?:(?only|not)\s+)?(?all|aural|bitmap|braille|dompdf|embossed|handheld|paged|print|projection|screen|speech|static|tty|tv|visual))|(?:\(\s*(?(?:(?:(?:min|max)-)?(?:width|height))|orientation|[^:]*?)\s*(?:\:\s*(?.*?)\s*)?\))))'; + /* * The highest possible specificity is 0x01000000 (and that is only for author * stylesheets, as it is for inline styles). Origin precedence can be achieved by @@ -87,7 +115,7 @@ class Stylesheet /** * Array of currently defined styles * - * @var Style[][] + * @var array */ private $_styles; @@ -248,6 +276,16 @@ function get_base_path() return $this->_base_path; } + /** + * Get all registered styles as an associative array, indexed by selector. + * + * @return array + */ + public function get_styles(): array + { + return $this->_styles; + } + /** * Return the array of page styles * @@ -347,7 +385,6 @@ function load_css_file($file, $origin = self::ORIG_AUTHOR) $good_mime_type = true; - // See http://the-stickman.com/web-development/php/getting-http-response-headers-when-using-file_get_contents/ if (isset($http_response_header) && !$this->_dompdf->getQuirksmode()) { foreach ($http_response_header as $_header) { if (preg_match("@Content-Type:\s*([\w/]+)@i", $_header, $matches) && @@ -1087,64 +1124,56 @@ function apply_styles(FrameTree $tree) // Merge the new styles with the inherited styles $acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES; $acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType(); + foreach ($applied_styles as $arr) { /** @var Style $s */ foreach ($arr as $s) { $media_queries = $s->get_media_queries(); - foreach ($media_queries as $media_query) { - list($media_query_feature, $media_query_value) = $media_query; - // if any of the Style's media queries fail then do not apply the style - //TODO: When the media query logic is fully developed we should not apply the Style when any of the media queries fail or are bad, per https://www.w3.org/TR/css3-mediaqueries/#error-handling - if (in_array($media_query_feature, self::$VALID_MEDIA_TYPES)) { - if ((strlen($media_query_feature) === 0 && !in_array($media_query, $acceptedmedia)) || (in_array($media_query, $acceptedmedia) && $media_query_value == "not")) { - continue (3); - } - } else { - switch ($media_query_feature) { - case "height": - if ($paper_height !== (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "min-height": - if ($paper_height < (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "max-height": - if ($paper_height > (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "width": - if ($paper_width !== (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "min-width": - //if (min($paper_width, $media_query_width) === $paper_width) { - if ($paper_width < (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "max-width": - //if (max($paper_width, $media_query_width) === $paper_width) { - if ($paper_width > (float)$style->length_in_pt($media_query_value)) { - continue (3); - } - break; - case "orientation": - if ($paper_orientation !== $media_query_value) { - continue (3); - } - break; - default: - Helpers::record_warnings(E_USER_WARNING, "Unknown media query: $media_query_feature", __FILE__, __LINE__); - break; + if (count($media_queries) > 0) { + $media_query_match = false; + foreach ($media_queries as $media_query_group) { + foreach ($media_query_group as $media_query) { + list($media_query_feature, $media_query_value, $media_query_operator) = $media_query; + switch ($media_query_feature) { + case "height": + $feature_match = $paper_height === (float)$style->length_in_pt($media_query_value); + break; + case "min-height": + $feature_match = $paper_height >= (float)$style->length_in_pt($media_query_value); + break; + case "max-height": + $feature_match = $paper_height <= (float)$style->length_in_pt($media_query_value); + break; + case "width": + $feature_match = $paper_width === (float)$style->length_in_pt($media_query_value); + break; + case "min-width": + $feature_match = $paper_width >= (float)$style->length_in_pt($media_query_value); + break; + case "max-width": + $feature_match = $paper_width <= (float)$style->length_in_pt($media_query_value); + break; + case "orientation": + $feature_match = $paper_orientation === $media_query_value; + break; + case "type": + $feature_match = in_array($media_query_value, $acceptedmedia, true); + break; + default: + Helpers::record_warnings(E_USER_WARNING, "Unknown media query: $media_query_feature", __FILE__, __LINE__); + continue (2); // unknown query, move to the next grouping + } + $negate = $media_query_operator === "not"; + if ($negate xor !$feature_match) { + continue (2); // failed query match, move to the next grouping + } } + $media_query_match = true; + } + if (!$media_query_match) { + continue; } } - $style->merge($s); } } @@ -1209,83 +1238,100 @@ private function _parse_css($str) "/-->$/" ], "", $str); - // FIXME: handle '{' within strings, e.g. [attr="string {}"] + // shim constants for string interpolation + $pattern_atimport_string = str_replace("CSS_STRING", "CSS_ATIMPORT_STRING", self::PATTERN_CSS_STRING); + $pattern_atimport_url = str_replace("CSS_URL_FN", "CSS_ATIMPORT_URL_FN", self::PATTERN_CSS_URL_FN); + $pattern_media_query = self::PATTERN_MEDIA_QUERY; // Something more legible: - $re = - "/\s* # Skip leading whitespace \n" . - "( @([^\s{]+)\s*([^{;]*) (?:;|({)) )? # Match @rules followed by ';' or '{' \n" . - "(?(1) # Only parse sub-sections if we're in an @rule... \n" . - " (?(4) # ...and if there was a leading '{' \n" . - " \s*( (?:(?>[^{}]+) ({)? # Parse rulesets and individual @page rules \n" . - " (?(6) (?>[^}]*) }) \s*)+? \n" . - " ) \n" . - " }) # Balancing '}' \n" . - "| # Branch to match regular rules (not preceded by '@') \n" . - "([^{]*{[^}]*})) # Parse normal rulesets \n" . - "/xs"; + // ... does not handle '{' within strings, e.g. [attr="string {}"] + $re = <<@(? + (?font-face) + |(?import) + |(?media) + |(?page) + |(?[\w-]*) + ))? + + # Branch to process segment following at-rule match + (?(CSS_ATRULE)(?: + (?(CSS_ATFONT)\s*{(?.*?)}) + (?(CSS_ATIMPORT)\s*(? + (? + {$pattern_atimport_string} + |{$pattern_atimport_url} + ) + (?.*?) + );) + (?(CSS_ATMEDIA)\s*(?[^{]*){(? (?:(?>[^{}]+) (?{)? + (?(CSS_ATMEDIA_BODY_BRACKET) (?>[^}]*) }) \s*)+? + )}) + (?(CSS_ATPAGE)\s*(?[^{]*){(?.*?)}) + (?(CSS_AT)\s*([^{;]*)(;|{(? (?:(?>[^{}]+) (?{)? + (?(CSS_AT_BODY_BRACKET) (?>[^}]*) }) \s*)+? + )})) + ) + + # Branch to match regular rules (not preceded by '@') + |(?[^{]*{[^}]*})) + /isx +EOL; if (preg_match_all($re, $css, $matches, PREG_SET_ORDER) === false) { - // An error occurred throw new Exception("Error parsing css file: preg_match_all() failed."); } - // After matching, the array indices are set as follows: - // - // [0] => complete text of match - // [1] => contains '@import ...;' or '@media {' if applicable - // [2] => text following @ for cases where [1] is set - // [3] => media types or full text following '@import ...;' - // [4] => '{', if present - // [5] => rulesets within media rules - // [6] => '{', within media rules - // [7] => individual rules, outside of media rules - // - - $media_query_regex = "/(?:((only|not)?\s*(" . implode("|", self::$VALID_MEDIA_TYPES) . "))|(\s*\(\s*((?:(min|max)-)?([\w\-]+))\s*(?:\:\s*(.*?)\s*)?\)))/isx"; - - //Helpers::pre_r($matches); + $media_query_regex = "/{$pattern_media_query}/isx"; + $accepted_media = self::$ACCEPTED_GENERIC_MEDIA_TYPES; + $accepted_media[] = $this->_dompdf->getOptions()->getDefaultMediaType(); foreach ($matches as $match) { - $match[2] = trim($match[2]); - - if ($match[2] !== "") { + if ($match["CSS_ATRULE_IDENTIFIER"] !== "") { + $atrule_identifier = strtolower($match["CSS_ATRULE_IDENTIFIER"]); // Handle @rules - switch ($match[2]) { + switch ($atrule_identifier) { case "import": - $this->_parse_import($match[3]); + $this->_parse_import($match["CSS_ATIMPORT_URL"], $match["CSS_ATIMPORT_MEDIA_QUERY"]); break; case "media": - $acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES; - $acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType(); - - $media_queries = preg_split("/\s*,\s*/", mb_strtolower(trim($match[3]))); + $mq = []; + $media_queries = preg_split("/\s*(,|\Wor\W)\s*/", mb_strtolower(trim($match["CSS_ATMEDIA_RULE"]))); foreach ($media_queries as $media_query) { - if (in_array($media_query, $acceptedmedia)) { - //if we have a media type match go ahead and parse the stylesheet - $this->_parse_sections($match[5]); - break; - } elseif (!in_array($media_query, self::$VALID_MEDIA_TYPES)) { - // otherwise conditionally parse the stylesheet assuming there are parseable media queries - if (preg_match_all($media_query_regex, $media_query, $media_query_matches, PREG_SET_ORDER) !== false) { - $mq = []; - foreach ($media_query_matches as $media_query_match) { - if (empty($media_query_match[1]) === false) { - $media_query_feature = strtolower($media_query_match[3]); - $media_query_value = strtolower($media_query_match[2]); - $mq[] = [$media_query_feature, $media_query_value]; - } elseif (empty($media_query_match[4]) === false) { - $media_query_feature = strtolower($media_query_match[5]); - $media_query_value = (array_key_exists(8, $media_query_match) ? strtolower($media_query_match[8]) : null); - $mq[] = [$media_query_feature, $media_query_value]; - } - } - $this->_parse_sections($match[5], $mq); - break; + $media_query_matches = []; + if (preg_match_all($media_query_regex, $media_query, $media_query_matches, PREG_SET_ORDER) === false) { + continue; + } + + $mq_grouping = []; + foreach ($media_query_matches as $media_query_match) { + if (empty($media_query_match["CSS_MEDIA_QUERY_TYPE"]) === false) { + $media_query_feature = "type"; + $media_query_value = strtolower($media_query_match["CSS_MEDIA_QUERY_TYPE"]); + $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]); + } elseif (empty($media_query_match["CSS_MEDIA_QUERY_FEATURE"]) === false) { + $media_query_feature = strtolower($media_query_match["CSS_MEDIA_QUERY_FEATURE"]); + $media_query_value = (array_key_exists("CSS_MEDIA_QUERY_CONDITION", $media_query_match) ? strtolower($media_query_match["CSS_MEDIA_QUERY_CONDITION"]) : null); + $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]); + } else { + // partial error handling implementation per https://www.w3.org/TR/css3-mediaqueries/#error-handling + $media_query_feature = "type"; + $media_query_value = "all"; + $media_query_operator = "not"; } + $mq_grouping[] = [$media_query_feature, $media_query_value, $media_query_operator]; + } + if (count($mq_grouping) > 0) { + $mq[] = $mq_grouping; } } + $this->_parse_sections($match["CSS_ATMEDIA_BODY"], $mq); break; case "page": @@ -1309,7 +1355,7 @@ private function _parse_css($str) //assign it to the tag, possibly only for the css of the correct media type. // If the page has a name, skip the style. - $page_selector = trim($match[3]); + $page_selector = trim($match["CSS_ATPAGE_RULE"]); $key = null; switch ($page_selector) { @@ -1332,14 +1378,14 @@ private function _parse_css($str) // Store the style for later... if (empty($this->_page_styles[$key])) { - $this->_page_styles[$key] = $this->_parse_properties($match[5]); + $this->_page_styles[$key] = $this->_parse_properties($match["CSS_ATPAGE_BODY"]); } else { - $this->_page_styles[$key]->merge($this->_parse_properties($match[5])); + $this->_page_styles[$key]->merge($this->_parse_properties($match["CSS_ATPAGE_BODY"])); } break; case "font-face": - $this->_parse_font_face($match[5]); + $this->_parse_font_face($match["CSS_ATFONT_BODY"]); break; default: @@ -1350,8 +1396,8 @@ private function _parse_css($str) continue; } - if ($match[7] !== "") { - $this->_parse_sections($match[7]); + if ($match["CSS_RULESET"] !== "") { + $this->_parse_sections($match["CSS_RULESET"]); } } } @@ -1366,23 +1412,35 @@ private function _parse_css($str) public function resolve_url($val): string { $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss(); - $parsed_url = "none"; + static $pattern = "/" . self::PATTERN_CSS_URL_FN . "/isx"; if ($val === null || $val === "" || strcasecmp($val, "none") === 0) { $path = "none"; - } elseif (strncasecmp($val, "url(", 4) !== 0) { - $path = "none"; //Don't resolve no image -> otherwise would prefix path and no longer recognize as none - } else { - $val = preg_replace("/url\(\s*['\"]?([^'\")]+)['\"]?\s*\)/i", "\\1", trim($val)); - + } elseif (preg_match($pattern, $val, $matches)) { // Resolve the url now in the context of the current stylesheet - $path = Helpers::build_url($this->_protocol, + $url = $matches["CSS_URL_FN_VALUE"]; + switch ($matches["CSS_URL_FN_QUOTE"]) { + case "\"": + $url = str_replace("\\\"", "\"", $url); + break; + case "'": + $url = str_replace("\\'", "'", $url); + break; + default: + $url = str_replace(["\\(", "\\)"], ["(", ")"], $url); + break; + } + $path = Helpers::build_url( + $this->_protocol, $this->_base_host, $this->_base_path, - $val); + $url + ); if ($path === null) { $path = "none"; } + } else { + $path = "none"; } if ($DEBUGCSS) { $parsed_url = Helpers::explode_url($path); @@ -1395,54 +1453,114 @@ public function resolve_url($val): string } /** - * parse @import{} sections + * parse @import at-rule * * @param string $url the url of the imported CSS file */ - private function _parse_import($url) + private function _parse_import($url, $import_media_query) { - $arr = preg_split("/[\s\n,]/", $url, -1, PREG_SPLIT_NO_EMPTY); - $url = array_shift($arr); - $accept = false; + // if URL is a CSS string, wrap it in the url function for parsing by the resolve_url method + if (mb_strpos($url, "url(") === false) { + $url = "url($url)"; + } + if (($url = $this->resolve_url($url)) === "none") { + return; + } + + // Store our current base url properties in case the new url is elsewhere + $protocol = $this->_protocol; + $host = $this->_base_host; + $path = $this->_base_path; + + $media_query_regex = "/" . self::PATTERN_MEDIA_QUERY . "/isx"; + $media_queries = preg_split("/\s*(,|\Wor\W)\s*/", mb_strtolower(trim($import_media_query ?? ""))); + if (count($media_queries) === 0) { + $this->load_css_file($url, $this->_current_origin); + } else { + // Set the page width, height, and orientation based on the canvas paper size + $canvas = $this->_dompdf->getCanvas(); + $paper_width = $canvas->get_width(); + $paper_height = $canvas->get_height(); + $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait"); + + $style = $this->_page_styles["base"] ?? new Style($this); + if (is_array($style->size)) { + $paper_width = $style->size[0]; + $paper_height = $style->size[1]; + $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait"); + } - if (count($arr) > 0) { $acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES; $acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType(); - // @import url media_type [media_type...] - foreach ($arr as $type) { - if (in_array(mb_strtolower(trim($type)), $acceptedmedia)) { - $accept = true; - break; + foreach ($media_queries as $media_query) { + $media_query_matches = []; + if (preg_match_all($media_query_regex, $media_query, $media_query_matches, PREG_SET_ORDER) === false) { + continue; } - } - - } else { - // unconditional import - $accept = true; - } - if ($accept) { - // Store our current base url properties in case the new url is elsewhere - $protocol = $this->_protocol; - $host = $this->_base_host; - $path = $this->_base_path; + foreach ($media_query_matches as $media_query_match) { + if (empty($media_query_match["CSS_MEDIA_QUERY_TYPE"]) === false) { + $media_query_feature = "type"; + $media_query_value = strtolower($media_query_match["CSS_MEDIA_QUERY_TYPE"]); + $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]); + } elseif (empty($media_query_match["CSS_MEDIA_QUERY_FEATURE"]) === false) { + $media_query_feature = strtolower($media_query_match["CSS_MEDIA_QUERY_FEATURE"]); + $media_query_value = (array_key_exists("CSS_MEDIA_QUERY_CONDITION", $media_query_match) ? strtolower($media_query_match["CSS_MEDIA_QUERY_CONDITION"]) : null); + $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]); + } else { + // partial error handling implementation per https://www.w3.org/TR/css3-mediaqueries/#error-handling + $media_query_feature = "type"; + $media_query_value = "all"; + $media_query_operator = "not"; + } - // $url = str_replace(array('"',"url", "(", ")"), "", $url); - // If the protocol is php, assume that we will import using file:// - // $url = Helpers::build_url($protocol === "php://" ? "file://" : $protocol, $host, $path, $url); - // Above does not work for subfolders and absolute urls. - // Todo: As above, do we need to replace php or file to an empty protocol for local files? + switch ($media_query_feature) { + case "height": + $feature_match = $paper_height === (float)$style->length_in_pt($media_query_value); + break; + case "min-height": + $feature_match = $paper_height >= (float)$style->length_in_pt($media_query_value); + break; + case "max-height": + $feature_match = $paper_height <= (float)$style->length_in_pt($media_query_value); + break; + case "width": + $feature_match = $paper_width === (float)$style->length_in_pt($media_query_value); + break; + case "min-width": + $feature_match = $paper_width >= (float)$style->length_in_pt($media_query_value); + break; + case "max-width": + $feature_match = $paper_width <= (float)$style->length_in_pt($media_query_value); + break; + case "orientation": + $feature_match = $paper_orientation === $media_query_value; + break; + case "type": + $feature_match = in_array($media_query_value, $acceptedmedia, true); + break; + default: + Helpers::record_warnings(E_USER_WARNING, "Unknown media query: $media_query_feature", __FILE__, __LINE__); + continue (2); + } + $negate = $media_query_operator === "not"; + if ($negate xor !$feature_match) { + continue (2); + } + } - if (($url = $this->resolve_url($url)) !== "none") { - $this->load_css_file($url); + //TODO: pass media queries as an argument to load_css_file and apply to all contained styles + // to better accommodate styling content in, for example, documents with varying page orientations + $this->load_css_file($url, $this->_current_origin); + break; // stop here so we don't load the same CSS more than once (at least until we implement that TODO) } - - // Restore the current base url - $this->_protocol = $protocol; - $this->_base_host = $host; - $this->_base_path = $path; } + + // Restore the current base url + $this->_protocol = $protocol; + $this->_base_host = $host; + $this->_base_path = $path; } /** @@ -1455,23 +1573,27 @@ private function _parse_font_face($str) { $descriptors = $this->_parse_properties($str); - preg_match_all("/(url|local)\s*\(\s*[\"\']?([^\"\'\)]+)[\"\']?\s*\)\s*(format\s*\(\s*[\"\']?([^\"\'\)]+)[\"\']?\s*\))?/i", $descriptors->src, $src); + preg_match_all("/" . self::PATTERN_CSS_LOCAL_FN . "|" . self::PATTERN_CSS_URL_FN . "\s*(?format\s*\((?collection|embedded-opentype|opentype|svg|truetype|woff|woff2|" . self::PATTERN_CSS_STRING . ")\))?/i", $descriptors->src, $sources, PREG_SET_ORDER); $valid_sources = []; - foreach ($src[0] as $i => $value) { - $source = [ - "local" => strtolower($src[1][$i]) === "local", - "uri" => $src[2][$i], - "format" => strtolower($src[4][$i]), - "path" => Helpers::build_url($this->_protocol, $this->_base_host, $this->_base_path, $src[2][$i]), - ]; - - if (!$source["local"] && in_array($source["format"], ["", "truetype"]) && $source["path"] !== null) { - $valid_sources[] = $source; + foreach ($sources as $source) { + $url_value = $source["CSS_URL_FN_VALUE"] ?? ""; + $format = strtolower($source["CSS_STRING_VALUE"] ?? $source["FORMAT_VALUE"] ?? "truetype"); + + if ($url_value !== "" && $format === "truetype") { + $url = Helpers::build_url($this->_protocol, $this->_base_host, $this->_base_path, $url_value); + if ($url === null) { + continue; + } + $source_info = [ + "uri" => $url_value, + "format" => $format, + "path" => $url, + ]; + $valid_sources[] = $source_info; } } - // No valid sources if (empty($valid_sources)) { return; } @@ -1482,7 +1604,11 @@ private function _parse_font_face($str) "style" => $descriptors->font_style, ]; - $this->getFontMetrics()->registerFont($style, $valid_sources[0]["path"], $this->_dompdf->getHttpContext()); + foreach ($valid_sources as $valid_source) { + if ($this->fontMetrics->registerFont($style, $valid_source["path"], $this->_dompdf->getHttpContext())) { + break; + } + } } /** @@ -1496,39 +1622,28 @@ private function _parse_font_face($str) */ private function _parse_properties($str) { - $properties = preg_split("/;(?=(?:[^\(]*\([^\)]*\))*(?![^\)]*\)))/", $str); $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss(); if ($DEBUGCSS) { print '[_parse_properties'; } - // Create the style + // Split on non-escaped semicolons which are not part of an unquoted + // `url()` declaration. Semicolons in strings are not detected here, and + // as a consequence, should be escaped if used in a string + $urlEnd = "(?> (\\\\[\"'()] | [^\"'()])* ) (?set_prop($prop_name, $value, $important, false); } - if ($DEBUGCSS) print '_parse_properties]'; + + if ($DEBUGCSS) { + print '_parse_properties]'; + } return $style; } diff --git a/src/Dompdf.php b/src/Dompdf.php index db02db019..6a437851e 100644 --- a/src/Dompdf.php +++ b/src/Dompdf.php @@ -327,13 +327,16 @@ public function load_html_file($file) } /** - * Loads an HTML file - * Parse errors are stored in the global array _dompdf_warnings. + * Loads an HTML file. * - * @param string $file a filename or url to load - * @param string $encoding Encoding of $file + * If no encoding is given or set via `Content-Type` header, the document + * encoding specified via `` tag is used. An existing Unicode BOM + * always takes precedence. * - * @throws Exception + * Parse errors are stored in the global array `$_dompdf_warnings`. + * + * @param string $file A filename or URL to load. + * @param string|null $encoding Encoding of the file. */ public function loadHtmlFile($file, $encoding = null) { @@ -394,7 +397,12 @@ public function load_html($str, $encoding = null) $this->loadHtml($str, $encoding); } - public function loadDOM($doc, $quirksmode = false) { + /** + * @param DOMDocument $doc + * @param bool $quirksmode + */ + public function loadDOM($doc, $quirksmode = false) + { // Remove #text children nodes in nodes that shouldn't have $tag_names = ["html", "head", "table", "tbody", "thead", "tfoot", "tr"]; foreach ($tag_names as $tag_name) { @@ -411,62 +419,90 @@ public function loadDOM($doc, $quirksmode = false) { } /** - * Loads an HTML string - * Parse errors are stored in the global array _dompdf_warnings. + * Loads an HTML document from a string. + * + * If no encoding is given, the document encoding specified via `` + * tag is used. An existing Unicode BOM always takes precedence. * - * @param string $str HTML text to load - * @param string $encoding Encoding of $str + * Parse errors are stored in the global array `$_dompdf_warnings`. + * + * @param string $str The HTML to load. + * @param string|null $encoding Encoding of the string. */ public function loadHtml($str, $encoding = null) { $this->setPhpConfig(); - // Determine character encoding when $encoding parameter not used - if ($encoding === null) { - mb_detect_order('auto'); - if (($encoding = mb_detect_encoding($str, null, true)) === false) { - - //"auto" is expanded to "ASCII,JIS,UTF-8,EUC-JP,SJIS" - $encoding = "auto"; - } + // Detect Unicode via BOM, taking precedence over the given encoding. + // Remove the mark, as it is treated as document text by DOMDocument. + // http://us2.php.net/manual/en/function.mb-detect-encoding.php#91051 + if (strncmp($str, "\xFE\xFF", 2) === 0) { + $str = substr($str, 2); + $encoding = "UTF-16BE"; + } elseif (strncmp($str, "\xFF\xFE", 2) === 0) { + $str = substr($str, 2); + $encoding = "UTF-16LE"; + } elseif (strncmp($str, "\xEF\xBB\xBF", 3) === 0) { + $str = substr($str, 3); + $encoding = "UTF-8"; } - if (in_array(strtoupper($encoding), array('UTF-8','UTF8')) === false) { - $str = mb_convert_encoding($str, 'UTF-8', $encoding); + // Convert document using the given encoding + $encodingGiven = $encoding !== null && $encoding !== ""; + + if ($encodingGiven && !in_array(strtoupper($encoding), ["UTF-8", "UTF8"], true)) { + $converted = mb_convert_encoding($str, "UTF-8", $encoding); - //Update encoding after converting - $encoding = 'UTF-8'; + if ($converted !== false) { + $str = $converted; + } } - $metatags = [ - '@]*charset\s*=\s*["\']?\s*([^"\' ]+)@i', + // Parse document encoding from `` tag ... + $charset = "(?[a-z0-9\-]+)"; + $contentType = "http-equiv\s*=\s* ([\"']?)\s* Content-Type"; + $contentStart = "content\s*=\s* ([\"']?)\s* [\w\/]+ \s*;\s* charset\s*=\s*"; + $metaTags = [ + "/]* $contentType \s*\g1\s* $contentStart $charset \s*\g2 [^>]*>/isx", // + "/]* $contentStart $charset \s*\g1\s* $contentType \s*\g3 [^>]*>/isx", // + "/]* charset\s*=\s* ([\"']?)\s* $charset \s*\g1 [^>]*>/isx", // ]; - foreach ($metatags as $metatag) { - if (preg_match($metatag, $str, $matches)) { - if (isset($matches[1]) && in_array($matches[1], mb_list_encodings())) { - $document_encoding = $matches[1]; - break; - } + + foreach ($metaTags as $pattern) { + if (preg_match($pattern, $str, $matches, PREG_OFFSET_CAPTURE)) { + [$documentEncoding, $offset] = $matches["charset"]; + break; } } - if (isset($document_encoding) && in_array(strtoupper($document_encoding), ['UTF-8','UTF8']) === false) { - $str = preg_replace('/charset=([^\s"]+)/i', 'charset=UTF-8', $str); - } elseif (isset($document_encoding) === false && strpos($str, '') !== false) { - $str = str_replace('', '', $str); - } elseif (isset($document_encoding) === false) { - $str = '' . $str; + + // ... and replace it with UTF-8; add a corresponding `` tag if + // missing. This is to ensure that `DOMDocument` handles the document + // encoding properly, as it will mess up the encoding if the charset + // declaration is missing or different from the actual encoding + if (isset($documentEncoding) && isset($offset)) { + if (!in_array(strtoupper($documentEncoding), ["UTF-8", "UTF8"], true)) { + $str = substr($str, 0, $offset) . "UTF-8" . substr($str, $offset + strlen($documentEncoding)); + } + } elseif (($headPos = stripos($str, "")) !== false) { + $str = substr($str, 0, $headPos + 6) . '' . substr($str, $headPos + 6); + } else { + $str = '' . $str; } - // remove BOM mark from UTF-8, it's treated as document text by DOMDocument - // FIXME: roll this into the encoding detection using UTF-8/16/32 BOM (http://us2.php.net/manual/en/function.mb-detect-encoding.php#91051)? - if (substr($str, 0, 3) == chr(0xEF) . chr(0xBB) . chr(0xBF)) { - $str = substr($str, 3); + // If no encoding was passed, use the document encoding, falling back to + // auto-detection + $fallbackEncoding = $documentEncoding ?? "auto"; + + if (!$encodingGiven && !in_array(strtoupper($fallbackEncoding), ["UTF-8", "UTF8"], true)) { + $converted = mb_convert_encoding($str, "UTF-8", $fallbackEncoding); + + if ($converted !== false) { + $str = $converted; + } } // Store parsing warnings as messages - set_error_handler([Helpers::class, 'record_warnings']); + set_error_handler([Helpers::class, "record_warnings"]); try { // @todo Take the quirksmode into account @@ -474,12 +510,12 @@ public function loadHtml($str, $encoding = null) // http://hsivonen.iki.fi/doctype/ $quirksmode = false; - $html5 = new HTML5(['encoding' => $encoding, 'disable_html_ns' => true]); + $html5 = new HTML5(["encoding" => "UTF-8", "disable_html_ns" => true]); $dom = $html5->loadHTML($str); // extra step to normalize the HTML document structure // see Masterminds/html5-php#166 - $doc = new DOMDocument("1.0", $encoding); + $doc = new DOMDocument("1.0", "UTF-8"); $doc->preserveWhiteSpace = true; $doc->loadHTML($html5->saveHTML($dom), LIBXML_NOWARNING | LIBXML_NOERROR); @@ -552,8 +588,10 @@ private function processHtml() switch (strtolower($tag->nodeName)) { // load tags case "link": - if (mb_strtolower(stripos($tag->getAttribute("rel"), "stylesheet") !== false) || // may be "appendix stylesheet" - mb_strtolower($tag->getAttribute("type")) === "text/css" + if ( + (stripos($tag->getAttribute("rel"), "stylesheet") !== false // may be "appendix stylesheet" + || mb_strtolower($tag->getAttribute("type")) === "text/css") + && stripos($tag->getAttribute("rel"), "alternate") === false // don't load "alternate stylesheet" ) { //Check if the css file is for an accepted media type //media not given then always valid diff --git a/src/FontMetrics.php b/src/FontMetrics.php index c779342e5..52cc3c7e9 100644 --- a/src/FontMetrics.php +++ b/src/FontMetrics.php @@ -130,9 +130,6 @@ private function loadFontFamiliesLegacy() $fontDir = $this->options->getFontDir(); $rootDir = $this->options->getRootDir(); - if (!defined("DOMPDF_DIR")) { define("DOMPDF_DIR", $rootDir); } - if (!defined("DOMPDF_FONT_DIR")) { define("DOMPDF_FONT_DIR", $fontDir); } - $cacheDataClosure = require $legacyCacheFile; $cacheData = is_array($cacheDataClosure) ? $cacheDataClosure : $cacheDataClosure($fontDir, $rootDir); if (is_array($cacheData)) { @@ -370,7 +367,7 @@ public function mapTextToFonts(string $text, array $fontFamilies, string $subtyp } $mapped_font = null; foreach ($fonts as $font) { - if ($this->canvas->font_supports_text($font, $char)) { + if ($this->canvas->font_supports_char($font, $char)) { $mapped_font = $font; break; } diff --git a/src/Frame.php b/src/Frame.php index 84236feb3..53fedeaa6 100644 --- a/src/Frame.php +++ b/src/Frame.php @@ -1137,6 +1137,12 @@ public function remove_child(Frame $child, $update_node = true) $child->_prev_sibling = null; $child->_parent = null; + // Force an update to the cached decorator parent + $decorator = $child->get_decorator(); + if ($decorator !== null) { + $decorator->get_parent(false); + } + return $child; } diff --git a/src/FrameDecorator/AbstractFrameDecorator.php b/src/FrameDecorator/AbstractFrameDecorator.php index c24ce675f..14aca7229 100644 --- a/src/FrameDecorator/AbstractFrameDecorator.php +++ b/src/FrameDecorator/AbstractFrameDecorator.php @@ -841,13 +841,12 @@ public function lookup_counter_frame( * @param string $id * @param string $type * - * @return bool|string + * @return string * * TODO: What version is the best : this one or the one in ListBullet ? */ - public function counter_value(string $id = self::DEFAULT_COUNTER, string $type = "decimal") + public function counter_value(string $id = self::DEFAULT_COUNTER, string $type = "decimal"): string { - $type = mb_strtolower($type); $value = $this->_counters[$id] ?? 0; switch ($type) { @@ -862,7 +861,7 @@ public function counter_value(string $id = self::DEFAULT_COUNTER, string $type = return Helpers::dec2roman($value); case "upper-roman": - return mb_strtoupper(Helpers::dec2roman($value)); + return strtoupper(Helpers::dec2roman($value)); case "lower-latin": case "lower-alpha": diff --git a/src/FrameDecorator/Block.php b/src/FrameDecorator/Block.php index 1fcf134d8..dd95209cf 100644 --- a/src/FrameDecorator/Block.php +++ b/src/FrameDecorator/Block.php @@ -166,7 +166,7 @@ public function remove_frames_from_line(Frame $frame): void $i = $this->_cl; $j = null; - while ($i > 0) { + while ($i >= 0) { $line = $this->_line_boxes[$i]; foreach ($line->get_frames() as $index => $f) { if ($frame === $f) { diff --git a/src/FrameDecorator/Image.php b/src/FrameDecorator/Image.php index 92ac491a4..bbfb13001 100644 --- a/src/FrameDecorator/Image.php +++ b/src/FrameDecorator/Image.php @@ -43,7 +43,9 @@ class Image extends AbstractFrameDecorator function __construct(Frame $frame, Dompdf $dompdf) { parent::__construct($frame, $dompdf); - $url = $frame->get_node()->getAttribute("src"); + + $node = $frame->get_node(); + $url = $node->getAttribute("src"); $debug_png = $dompdf->getOptions()->getDebugPng(); if ($debug_png) { @@ -58,9 +60,7 @@ function __construct(Frame $frame, Dompdf $dompdf) $dompdf->getOptions() ); - if (Cache::is_broken($this->_image_url) && - $alt = $frame->get_node()->getAttribute("alt") - ) { + if (Cache::is_broken($this->_image_url) && ($alt = $node->getAttribute("alt")) !== "") { $fontMetrics = $dompdf->getFontMetrics(); $style = $frame->get_style(); $font = $style->font_family; @@ -68,7 +68,7 @@ function __construct(Frame $frame, Dompdf $dompdf) $word_spacing = $style->word_spacing; $letter_spacing = $style->letter_spacing; - $style->width = (4 / 3) * $fontMetrics->getTextWidth($alt, $font, $size, $word_spacing, $letter_spacing); + $style->width = $fontMetrics->getTextWidth($alt, $font, $size, $word_spacing, $letter_spacing); $style->height = $fontMetrics->getFontHeight($font, $size); } } diff --git a/src/FrameDecorator/ListBulletImage.php b/src/FrameDecorator/ListBulletImage.php index d921929c2..df6c105c7 100644 --- a/src/FrameDecorator/ListBulletImage.php +++ b/src/FrameDecorator/ListBulletImage.php @@ -8,7 +8,6 @@ use Dompdf\Dompdf; use Dompdf\Frame; -use Dompdf\Helpers; use Dompdf\Image\Cache; /** diff --git a/src/FrameDecorator/Page.php b/src/FrameDecorator/Page.php index ece29cc0a..fc285531f 100644 --- a/src/FrameDecorator/Page.php +++ b/src/FrameDecorator/Page.php @@ -7,6 +7,7 @@ namespace Dompdf\FrameDecorator; use Dompdf\Dompdf; +use Dompdf\Exception; use Dompdf\Helpers; use Dompdf\Frame; use Dompdf\Renderer; @@ -492,7 +493,10 @@ protected function _page_break_allowed(Frame $frame) // Check if the page_break_inside property is not 'avoid' // for the parent table or any of its ancestors $table = Table::find_parent_table($frame); - + if ($table === null) { + throw new Exception("Parent table not found for table row"); + } + $p = $table; while ($p) { if ($p->get_style()->page_break_inside === "avoid") { diff --git a/src/FrameDecorator/TableCell.php b/src/FrameDecorator/TableCell.php index d382164a3..7d06b55d6 100644 --- a/src/FrameDecorator/TableCell.php +++ b/src/FrameDecorator/TableCell.php @@ -17,11 +17,10 @@ */ class TableCell extends BlockFrameDecorator { - - protected $_resolved_borders; - protected $_content_height; - - //........................................................................ + /** + * @var float + */ + protected $content_height; /** * TableCell constructor. @@ -31,40 +30,35 @@ class TableCell extends BlockFrameDecorator function __construct(Frame $frame, Dompdf $dompdf) { parent::__construct($frame, $dompdf); - $this->_resolved_borders = []; - $this->_content_height = 0; + $this->content_height = 0.0; } - //........................................................................ - function reset() { parent::reset(); - $this->_resolved_borders = []; - $this->_content_height = 0; - $this->_frame->reset(); + $this->content_height = 0.0; } /** - * @return int + * @return float */ - function get_content_height() + public function get_content_height(): float { - return $this->_content_height; + return $this->content_height; } /** - * @param $height + * @param float $height */ - function set_content_height($height) + public function set_content_height(float $height): void { - $this->_content_height = $height; + $this->content_height = $height; } /** - * @param $height + * @param float $height */ - function set_cell_height($height) + public function set_cell_height(float $height): void { $style = $this->get_style(); $v_space = (float)$style->length_in_pt( @@ -82,7 +76,7 @@ function set_cell_height($height) $new_height = $height - $v_space; $style->set_used("height", $new_height); - if ($new_height > $this->_content_height) { + if ($new_height > $this->content_height) { $y_offset = 0; // Adjust our vertical alignment @@ -96,11 +90,11 @@ function set_cell_height($height) return; case "middle": - $y_offset = ($new_height - $this->_content_height) / 2; + $y_offset = ($new_height - $this->content_height) / 2; break; case "bottom": - $y_offset = $new_height - $this->_content_height; + $y_offset = $new_height - $this->content_height; break; } @@ -114,30 +108,4 @@ function set_cell_height($height) } } } - - /** - * @param $side - * @param $border_spec - */ - function set_resolved_border($side, $border_spec) - { - $this->_resolved_borders[$side] = $border_spec; - } - - /** - * @param $side - * @return mixed - */ - function get_resolved_border($side) - { - return $this->_resolved_borders[$side]; - } - - /** - * @return array - */ - function get_resolved_borders() - { - return $this->_resolved_borders; - } } diff --git a/src/FrameDecorator/Text.php b/src/FrameDecorator/Text.php index 9ca5d3af7..29b8ebc9b 100644 --- a/src/FrameDecorator/Text.php +++ b/src/FrameDecorator/Text.php @@ -23,7 +23,7 @@ class Text extends AbstractFrameDecorator protected $text_spacing; /** - * @var string + * @var string|null */ protected $mapped_font; @@ -152,12 +152,14 @@ public function recalculate_width(): float * Split the text in this frame at the offset specified. The remaining * text is added as a sibling frame following this one and is returned. * - * @param int $offset - * @return Frame|null + * @param int $offset + * @param bool $split_parent Whether to split parent inline frames. + * + * @return Text|null */ - function split_text($offset) + function split_text(int $offset, bool $split_parent = true): ?self { - if ($offset == 0) { + if ($offset === 0) { return null; } @@ -165,13 +167,31 @@ function split_text($offset) if ($split === false) { return null; } - + + /** @var Text */ $deco = $this->copy($split); + $style = $this->_frame->get_style(); + $split_style = $deco->get_style(); + + if ($this->mapped_font !== null) { + $split_style->set_used("font_family", $this->mapped_font); + $deco->mapped_font = $this->mapped_font; + } + + // Clear decoration widths at the split point. They might have been + // copied from the parent frame during inline reflow + $style->margin_right = 0.0; + $style->padding_right = 0.0; + $style->border_right_width = 0.0; + + $split_style->margin_left = 0.0; + $split_style->padding_left = 0.0; + $split_style->border_left_width = 0.0; $p = $this->get_parent(); $p->insert_child_after($deco, $this, false); - if ($p instanceof Inline) { + if ($split_parent && $p instanceof Inline) { $p->split($deco); } @@ -199,23 +219,21 @@ function set_text($text) * Determines the optimal font that applies to the frame and splits * the frame where the optimal font changes. */ - function apply_font_mapping() { - if (!empty($this->mapped_font)) { + function apply_font_mapping(): void + { + if ($this->mapped_font !== null) { return; } $fontMetrics = $this->_dompdf->getFontMetrics(); $style = $this->get_style(); - $families = array_map( - function ($value) { - return trim($value, " '\""); - }, - explode(",", $style->get_specified("font_family")) - ); - $charMapping = $fontMetrics->mapTextToFonts($this->get_text(), $families, $fontMetrics->getType($style->font_weight . ' ' . $style->font_style), 1); + $families = $style->get_font_family_computed(); + $subtype = $fontMetrics->getType($style->font_weight . ' ' . $style->font_style); + $charMapping = $fontMetrics->mapTextToFonts($this->get_text(), $families, $subtype, 1); + if (isset($charMapping[0])) { if ($charMapping[0]["length"] !== 0) { - $this->split_text($charMapping[0]["length"]); + $this->split_text($charMapping[0]["length"], false); } $mapped_font = $charMapping[0]["font"]; if ($mapped_font !== null) { diff --git a/src/FrameReflower/Block.php b/src/FrameReflower/Block.php index 9fb7184b6..1eda6106f 100644 --- a/src/FrameReflower/Block.php +++ b/src/FrameReflower/Block.php @@ -273,9 +273,9 @@ protected function _calculate_restricted_width() * * @return float */ - protected function _calculate_content_height() + protected function _calculate_content_height(): float { - $height = 0; + $height = 0.0; $lines = $this->_frame->get_line_boxes(); if (count($lines) > 0) { $last_line = end($lines); diff --git a/src/FrameReflower/Inline.php b/src/FrameReflower/Inline.php index 0be80e1c2..5091afdf9 100644 --- a/src/FrameReflower/Inline.php +++ b/src/FrameReflower/Inline.php @@ -134,14 +134,13 @@ function reflow(BlockFrameDecorator $block = null) return; } - // Add our margin, padding & border to the first and last children + // Add margin, padding & border width to the first and last children, + // so they are accounted for during text layout if (($f = $frame->get_first_child()) && $f instanceof TextFrameDecorator) { $f_style = $f->get_style(); $f_style->margin_left = $style->margin_left; $f_style->padding_left = $style->padding_left; $f_style->border_left_width = $style->border_left_width; - $f_style->border_left_style = $style->border_left_style; - $f_style->border_left_color = $style->border_left_color; } if (($l = $frame->get_last_child()) && $l instanceof TextFrameDecorator) { @@ -149,8 +148,6 @@ function reflow(BlockFrameDecorator $block = null) $l_style->margin_right = $style->margin_right; $l_style->padding_right = $style->padding_right; $l_style->border_right_width = $style->border_right_width; - $l_style->border_right_style = $style->border_right_style; - $l_style->border_right_color = $style->border_right_color; } $frame->position(); diff --git a/src/FrameReflower/TableCell.php b/src/FrameReflower/TableCell.php index f5ce35da6..bbc60b1ce 100644 --- a/src/FrameReflower/TableCell.php +++ b/src/FrameReflower/TableCell.php @@ -6,8 +6,10 @@ */ namespace Dompdf\FrameReflower; +use Dompdf\Exception; use Dompdf\FrameDecorator\Block as BlockFrameDecorator; use Dompdf\FrameDecorator\Table as TableFrameDecorator; +use Dompdf\FrameDecorator\TableCell as TableCellFrameDecorator; use Dompdf\Helpers; /** @@ -31,18 +33,23 @@ function __construct(BlockFrameDecorator $frame) */ function reflow(BlockFrameDecorator $block = null) { + /** @var TableCellFrameDecorator */ + $frame = $this->_frame; + $table = TableFrameDecorator::find_parent_table($frame); + if ($table === null) { + throw new Exception("Parent table not found for table cell"); + } + // Counters and generated content $this->_set_content(); - $style = $this->_frame->get_style(); - - $table = TableFrameDecorator::find_parent_table($this->_frame); + $style = $frame->get_style(); $cellmap = $table->get_cellmap(); - list($x, $y) = $cellmap->get_frame_position($this->_frame); - $this->_frame->set_position($x, $y); + [$x, $y] = $cellmap->get_frame_position($frame); + $frame->set_position($x, $y); - $cells = $cellmap->get_spanned_cells($this->_frame); + $cells = $cellmap->get_spanned_cells($frame); $w = 0; foreach ($cells["columns"] as $i) { @@ -51,7 +58,7 @@ function reflow(BlockFrameDecorator $block = null) } //FIXME? - $h = $this->_frame->get_containing_block("h"); + $h = $frame->get_containing_block("h"); $left_space = (float)$style->length_in_pt([$style->margin_left, $style->padding_left, @@ -80,19 +87,19 @@ function reflow(BlockFrameDecorator $block = null) // Adjust the first line based on the text-indent property $indent = (float)$style->length_in_pt($style->text_indent, $w); - $this->_frame->increase_line_width($indent); + $frame->increase_line_width($indent); - $page = $this->_frame->get_root(); + $page = $frame->get_root(); // Set the y position of the first line in the cell - $line_box = $this->_frame->get_current_line_box(); + $line_box = $frame->get_current_line_box(); $line_box->y = $line_y; // Set the containing blocks and reflow each child - foreach ($this->_frame->get_children() as $child) { + foreach ($frame->get_children() as $child) { $child->set_containing_block($content_x, $content_y, $cb_w, $h); $this->process_clear($child); - $child->reflow($this->_frame); + $child->reflow($frame); $this->process_float($child, $content_x, $cb_w); if ($page->is_full()) { @@ -101,14 +108,11 @@ function reflow(BlockFrameDecorator $block = null) } // Determine our height - $style_height = (float)$style->length_in_pt($style->height, $h); - - /** @var FrameDecorator\TableCell */ - $frame = $this->_frame; - - $frame->set_content_height($this->_calculate_content_height()); + $style_height = (float) $style->length_in_pt($style->height, $h); + $content_height = $this->_calculate_content_height(); + $height = max($style_height, $content_height); - $height = max($style_height, (float)$frame->get_content_height()); + $frame->set_content_height($content_height); // Let the cellmap know our height $cell_height = $height / count($cells["rows"]); @@ -127,7 +131,7 @@ function reflow(BlockFrameDecorator $block = null) $this->vertical_align(); // Handle relative positioning - foreach ($this->_frame->get_children() as $child) { + foreach ($frame->get_children() as $child) { $this->position_relative($child); } } diff --git a/src/FrameReflower/TableRow.php b/src/FrameReflower/TableRow.php index f84c1258b..76ac13f60 100644 --- a/src/FrameReflower/TableRow.php +++ b/src/FrameReflower/TableRow.php @@ -47,11 +47,11 @@ function reflow(BlockFrameDecorator $block = null) // Counters and generated content $this->_set_content(); - $this->_frame->position(); - $style = $this->_frame->get_style(); - $cb = $this->_frame->get_containing_block(); + $frame->position(); + $style = $frame->get_style(); + $cb = $frame->get_containing_block(); - foreach ($this->_frame->get_children() as $child) { + foreach ($frame->get_children() as $child) { $child->set_containing_block($cb); $child->reflow(); @@ -64,12 +64,16 @@ function reflow(BlockFrameDecorator $block = null) return; } - $table = TableFrameDecorator::find_parent_table($this->_frame); + $table = TableFrameDecorator::find_parent_table($frame); + if ($table === null) { + throw new Exception("Parent table not found for table row"); + } $cellmap = $table->get_cellmap(); - $style->set_used("width", $cellmap->get_frame_width($this->_frame)); - $style->set_used("height", $cellmap->get_frame_height($this->_frame)); - $this->_frame->set_position($cellmap->get_frame_position($this->_frame)); + $style->set_used("width", $cellmap->get_frame_width($frame)); + $style->set_used("height", $cellmap->get_frame_height($frame)); + + $frame->set_position($cellmap->get_frame_position($frame)); } /** diff --git a/src/FrameReflower/TableRowGroup.php b/src/FrameReflower/TableRowGroup.php index c8b19aa53..5745a73dd 100644 --- a/src/FrameReflower/TableRowGroup.php +++ b/src/FrameReflower/TableRowGroup.php @@ -6,6 +6,7 @@ */ namespace Dompdf\FrameReflower; +use Dompdf\Exception; use Dompdf\FrameDecorator\Block as BlockFrameDecorator; use Dompdf\FrameDecorator\Table as TableFrameDecorator; use Dompdf\FrameDecorator\TableRowGroup as TableRowGroupFrameDecorator; @@ -35,6 +36,8 @@ function reflow(BlockFrameDecorator $block = null) /** @var TableRowGroupFrameDecorator */ $frame = $this->_frame; $page = $frame->get_root(); + $parent = $frame->get_parent(); + $dompdf_generated = $parent->get_frame()->get_node()->nodeName === "dompdf_generated"; // Counters and generated content $this->_set_content(); @@ -54,7 +57,14 @@ function reflow(BlockFrameDecorator $block = null) } } + if ($page->is_full() && $dompdf_generated && $frame->get_parent() === null) { + return; + } + $table = TableFrameDecorator::find_parent_table($frame); + if ($table === null) { + throw new Exception("Parent table not found for table row group"); + } $cellmap = $table->get_cellmap(); // Stop reflow if a page break has occurred before the frame, in which diff --git a/src/FrameReflower/Text.php b/src/FrameReflower/Text.php index 789e9abe2..2d71ea59b 100644 --- a/src/FrameReflower/Text.php +++ b/src/FrameReflower/Text.php @@ -8,7 +8,6 @@ use Dompdf\Exception; use Dompdf\FontMetrics; -use Dompdf\Frame; use Dompdf\FrameDecorator\Block as BlockFrameDecorator; use Dompdf\FrameDecorator\Inline as InlineFrameDecorator; use Dompdf\FrameDecorator\Text as TextFrameDecorator; @@ -129,7 +128,7 @@ protected function pre_process_text(string $text): string * @param BlockFrameDecorator $block * @param bool $nowrap * - * @return bool|int + * @return int|false */ protected function line_break(string $text, BlockFrameDecorator $block, bool $nowrap = false) { @@ -164,8 +163,12 @@ protected function line_break(string $text, BlockFrameDecorator $block, bool $no return false; } + $force_first = $current_line->left == 0 + && $current_line->right == 0 + && $current_line->is_empty(); + if ($nowrap) { - return $current_line_width == 0 ? false : 0; + return $force_first ? false : 0; } // Split the text into words @@ -189,7 +192,7 @@ protected function line_break(string $text, BlockFrameDecorator $block, bool $no $word_width = $fontMetrics->getTextWidth($word, $font, $size, $word_spacing, $letter_spacing); $used_width = $width + $word_width + $mbp_width; - if (Helpers::lengthGreater($used_width, $available_width)) { + if ($used_width > 0 && Helpers::lengthGreater($used_width, $available_width)) { // If the previous split happened by soft hyphen, we have to // append its width again because the last hyphen of a line // won't be removed @@ -216,7 +219,7 @@ protected function line_break(string $text, BlockFrameDecorator $block, bool $no // The first word has overflowed. Force it onto the line, or as many // characters as fit if breaking words is allowed - if ($current_line_width == 0 && $width === 0.0) { + if ($force_first && $width === 0.0) { if ($sep === " ") { $word .= $sep; } @@ -227,8 +230,9 @@ protected function line_break(string $text, BlockFrameDecorator $block, bool $no if ($break_word) { $s = ""; + $len = mb_strlen($word); - for ($j = 0; $j < mb_strlen($word); $j++) { + for ($j = 0; $j < $len; $j++) { $c = mb_substr($word, $j, 1); $w = $fontMetrics->getTextWidth($s . $c, $font, $size, $word_spacing, $letter_spacing); @@ -252,7 +256,7 @@ protected function line_break(string $text, BlockFrameDecorator $block, bool $no /** * @param string $text - * @return bool|int + * @return int|false */ protected function newline_break(string $text) { @@ -276,7 +280,7 @@ protected function layout_line(BlockFrameDecorator $block): ?bool $text = $frame->get_text(); // Trim leading white space if this is the first text on the line - if ($current_line->w === 0.0 && !$frame->is_pre()) { + if ($current_line->is_empty() && !$frame->is_pre()) { $text = ltrim($text, " "); } @@ -350,7 +354,7 @@ protected function layout_line(BlockFrameDecorator $block): ?bool if ($split !== false && $split < mb_strlen($text)) { // Split the line $frame->set_text($text); - $frame->split_text($split); + $frame->split_text($split, true); $add_line = true; // Remove inner soft hyphens @@ -388,7 +392,6 @@ function reflow(BlockFrameDecorator $block = null) } $style = $frame->get_style(); - $font_metrics = $this->getFontMetrics(); // Handle text transform and white space $frame->set_text($this->pre_process_text($frame->get_text())); diff --git a/src/Helpers.php b/src/Helpers.php index f19b480e1..b0fe8f75f 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -319,7 +319,11 @@ public static function encodeURI($uri) { $score = [ '%23'=>'#' ]; - return strtr(rawurlencode(rawurldecode($uri)), array_merge($reserved, $unescaped, $score)); + return preg_replace( + '/%25([a-fA-F0-9]{2,2})/', + '%$1', + strtr(rawurlencode($uri), array_merge($reserved, $unescaped, $score)) + ); } /** @@ -601,19 +605,18 @@ public static function record_warnings($errno, $errstr, $errfile, $errline) public static function uniord(string $c, string $encoding = null) { if (function_exists("mb_ord")) { - if (version_compare(PHP_VERSION, '8.0.0', '<') && $encoding === null) { + if (PHP_VERSION_ID < 80000 && $encoding === null) { // in PHP < 8 the encoding argument, if supplied, must be a valid encoding $encoding = "UTF-8"; } - $ord = mb_ord($c, $encoding); - return $ord; + return mb_ord($c, $encoding); } - if ($encoding != "UTF-8") { - $c = mb_convert_encoding($c, $encoding); + if ($encoding != "UTF-8" && $encoding !== null) { + $c = mb_convert_encoding($c, "UTF-8", $encoding); } - $length = mb_strlen($c, '8bit'); + $length = mb_strlen(mb_substr($c, 0, 1), '8bit'); $ord = false; $bytes = []; $numbytes = 1; @@ -678,12 +681,11 @@ public static function uniord(string $c, string $encoding = null) public static function unichr(int $c, string $encoding = null) { if (function_exists("mb_chr")) { - if (version_compare(PHP_VERSION, '8.0.0', '<') && $encoding === null) { + if (PHP_VERSION_ID < 80000 && $encoding === null) { // in PHP < 8 the encoding argument, if supplied, must be a valid encoding $encoding = "UTF-8"; } - $chr = mb_chr($c, $encoding); - return $chr; + return mb_chr($c, $encoding); } $chr = false; @@ -802,13 +804,17 @@ public static function dompdf_getimagesize($filename, $context = null) * http://www.programmierer-forum.de/function-imagecreatefrombmp-welche-variante-laeuft-t143137.htm * Modified by Fabien Menager to support RGB555 BMP format */ - public static function imagecreatefrombmp($filename, $context = null) + public static function imagecreatefrombmp($filename) { if (!function_exists("imagecreatetruecolor")) { trigger_error("The PHP GD extension is required, but is not installed.", E_ERROR); return false; } + if (function_exists("imagecreatefrombmp") && ($im = imagecreatefrombmp($filename)) !== false) { + return $im; + } + // version 1.00 if (!($fh = fopen($filename, 'rb'))) { trigger_error('imagecreatefrombmp: Can not open ' . $filename, E_USER_WARNING); diff --git a/src/Image/Cache.php b/src/Image/Cache.php index 8e36aa2b7..b3e1d0e9e 100644 --- a/src/Image/Cache.php +++ b/src/Image/Cache.php @@ -31,6 +31,14 @@ class Cache */ protected static $tempImages = []; + /** + * Array of image references from an SVG document. + * Used to detect circular references across SVG documents. + * + * @var array + */ + protected static $svgRefs = []; + /** * The url to the "broken image" used when images can't be loaded * @@ -134,20 +142,28 @@ static function resolve_url($url, $protocol, $host, $base_path, Options $options $parser, function ($parser, $name, $attributes) use ($options, $parsed_url, $full_url) { if (strtolower($name) === "image") { + if (!\array_key_exists($full_url, self::$svgRefs)) { + self::$svgRefs[$full_url] = []; + } $attributes = array_change_key_case($attributes, CASE_LOWER); $urls = []; $urls[] = $attributes["xlink:href"] ?? ""; $urls[] = $attributes["href"] ?? ""; foreach ($urls as $url) { - if (!empty($url)) { - $inner_full_url = Helpers::build_url($parsed_url["protocol"], $parsed_url["host"], $parsed_url["path"], $url); - if ($inner_full_url === $full_url) { - throw new ImageException("SVG self-reference is not allowed", E_WARNING); - } - [$resolved_url, $type, $message] = self::resolve_url($url, $parsed_url["protocol"], $parsed_url["host"], $parsed_url["path"], $options); - if (!empty($message)) { - throw new ImageException("This SVG document references a restricted resource. $message", E_WARNING); - } + if (empty($url)) { + continue; + } + + $inner_full_url = Helpers::build_url($parsed_url["protocol"], $parsed_url["host"], $parsed_url["path"], $url); + if (empty($inner_full_url)) { + continue; + } + + self::detectCircularRef($full_url, $inner_full_url); + self::$svgRefs[$full_url][] = $inner_full_url; + [$resolved_url, $type, $message] = self::resolve_url($url, $parsed_url["protocol"], $parsed_url["host"], $parsed_url["path"], $options); + if (!empty($message)) { + throw new ImageException("This SVG document references a restricted resource. $message", E_WARNING); } } } @@ -178,6 +194,19 @@ function ($parser, $name, $attributes) use ($options, $parsed_url, $full_url) { return [$resolved_url, $type, $message]; } + static function detectCircularRef(string $src, string $target) + { + if (!\array_key_exists($target, self::$svgRefs)) { + return; + } + foreach (self::$svgRefs[$target] as $ref) { + if ($ref === $src) { + throw new ImageException("Circular external SVG image reference detected.", E_WARNING); + } + self::detectCircularRef($src, $ref); + } + } + /** * Register a temp file for the given original image file. * @@ -239,6 +268,7 @@ static function clear(bool $debugPng = false) self::$_cache = []; self::$tempImages = []; + self::$svgRefs = []; } static function detect_type($file, $context = null) diff --git a/src/LineBox.php b/src/LineBox.php index 11b83c15c..85ea8cc17 100644 --- a/src/LineBox.php +++ b/src/LineBox.php @@ -12,6 +12,7 @@ use Dompdf\FrameDecorator\Page; use Dompdf\FrameReflower\Text as TextFrameReflower; use Dompdf\Positioner\Inline as InlinePositioner; +use Iterator; /** * The line box class @@ -23,7 +24,6 @@ */ class LineBox { - /** * @var Block */ @@ -47,7 +47,7 @@ class LineBox /** * @var float */ - public $y = null; + public $y = 0.0; /** * @var float @@ -92,12 +92,10 @@ class LineBox public $inline = false; /** - * Class constructor - * * @param Block $frame the Block containing this line - * @param int $y + * @param float $y */ - public function __construct(Block $frame, $y = 0) + public function __construct(Block $frame, float $y = 0.0) { $this->_block_frame = $frame; $this->_frames = []; @@ -113,7 +111,7 @@ public function __construct(Block $frame, $y = 0) * * @return Frame[] */ - public function get_floats_inside(Page $root) + public function get_floats_inside(Page $root): array { $floating_frames = $root->get_floating_frames(); @@ -154,7 +152,7 @@ public function get_floats_inside(Page $root) return $childs; } - public function get_float_offsets() + public function get_float_offsets(): void { static $anti_infinite_loop = 10000; // FIXME smelly hack @@ -241,7 +239,7 @@ public function get_float_offsets() /** * @return float */ - public function get_width() + public function get_width(): float { return $this->left + $this->w + $this->right; } @@ -249,7 +247,7 @@ public function get_width() /** * @return Block */ - public function get_block_frame() + public function get_block_frame(): Block { return $this->_block_frame; } @@ -257,11 +255,19 @@ public function get_block_frame() /** * @return AbstractFrameDecorator[] */ - function &get_frames() + public function &get_frames(): array { return $this->_frames; } + /** + * @return bool + */ + public function is_empty(): bool + { + return $this->_frames === []; + } + /** * @param AbstractFrameDecorator $frame */ @@ -338,9 +344,9 @@ public function add_list_marker(ListBullet $marker): void * An iterator of all list markers and inline positioned frames of the line * box. * - * @return \Iterator + * @return Iterator */ - public function frames_to_align(): \Iterator + public function frames_to_align(): Iterator { yield from $this->list_markers; @@ -387,9 +393,6 @@ public function recalculate_width(): float return $this->w = $width; } - /** - * @return string - */ public function __toString(): string { $props = ["wc", "y", "w", "h", "left", "right", "br"]; @@ -402,11 +405,3 @@ public function __toString(): string return $s; } } - -/* -class LineBoxList implements Iterator { - private $_p = 0; - private $_lines = array(); - -} -*/ diff --git a/src/Options.php b/src/Options.php index 471fc5412..90f450ca8 100644 --- a/src/Options.php +++ b/src/Options.php @@ -212,6 +212,20 @@ class Options */ private $isPdfAEnabled = false; + /** + * List of allowed remote hosts + * + * Each value of the array must be a valid hostname. + * + * This will be used to filter which resources can be loaded in combination with + * isRemoteEnabled. If isRemoteEnabled is FALSE, then this will have no effect. + * + * Leave to NULL to allow any remote host. + * + * @var array|null + */ + private $allowedRemoteHosts = null; + /** * Enable inline JavaScript * @@ -373,7 +387,7 @@ public function set($attributes, $value = null) $this->setFontCache($value); } elseif ($key === 'chroot') { $this->setChroot($value); - } elseif ($key === 'allowedProtocols') { + } elseif ($key === 'allowedProtocols' || $key === 'allowed_protocols') { $this->setAllowedProtocols($value); } elseif ($key === 'logOutputFile' || $key === 'log_output_file') { $this->setLogOutputFile($value); @@ -395,6 +409,8 @@ public function set($attributes, $value = null) $this->setIsRemoteEnabled($value); } elseif ($key === 'isPdfAEnabled' || $key === 'is_pdf_a_enabled' || $key === 'enable_pdf_a') { $this->setIsPdfAEnabled($value); + } elseif ($key === 'allowedRemoteHosts' || $key === 'allowed_remote_hosts') { + $this->setAllowedRemoteHosts($value); } elseif ($key === 'isJavascriptEnabled' || $key === 'is_javascript_enabled' || $key === 'enable_javascript') { $this->setIsJavascriptEnabled($value); } elseif ($key === 'isHtml5ParserEnabled' || $key === 'is_html5_parser_enabled' || $key === 'enable_html5_parser') { @@ -442,7 +458,7 @@ public function get($key) return $this->getFontCache(); } elseif ($key === 'chroot') { return $this->getChroot(); - } elseif ($key === 'allowedProtocols') { + } elseif ($key === 'allowedProtocols' || $key === 'allowed_protocols') { return $this->getAllowedProtocols(); } elseif ($key === 'logOutputFile' || $key === 'log_output_file') { return $this->getLogOutputFile(); @@ -464,6 +480,8 @@ public function get($key) return $this->getIsRemoteEnabled(); } elseif ($key === 'isPdfAEnabled' || $key === 'is_pdf_a_enabled' || $key === 'enable_pdf_a') { $this->getIsPdfAEnabled(); + } elseif ($key === 'allowedRemoteHosts' || $key === 'allowed_remote_hosts') { + return $this->getAllowedProtocols(); } elseif ($key === 'isJavascriptEnabled' || $key === 'is_javascript_enabled' || $key === 'enable_javascript') { return $this->getIsJavascriptEnabled(); } elseif ($key === 'isHtml5ParserEnabled' || $key === 'is_html5_parser_enabled' || $key === 'enable_html5_parser') { @@ -1073,6 +1091,33 @@ public function isPdfAEnabled() return $this->getIsPdfAEnabled(); } + /** + * @param array|null $allowedRemoteHosts + * @return $this + */ + public function setAllowedRemoteHosts($allowedRemoteHosts) + { + if (is_array($allowedRemoteHosts)) { + // Set hosts to lowercase + foreach ($allowedRemoteHosts as &$host) { + $host = mb_strtolower($host); + } + + unset($host); + } + + $this->allowedRemoteHosts = $allowedRemoteHosts; + return $this; + } + + /** + * @return array|null + */ + public function getAllowedRemoteHosts() + { + return $this->allowedRemoteHosts; + } + /** * @param string $logOutputFile * @return $this @@ -1198,6 +1243,15 @@ public function validateRemoteUri(string $uri) return [false, "Remote file requested, but remote file download is disabled."]; } + if (is_array($this->allowedRemoteHosts) && count($this->allowedRemoteHosts) > 0) { + $host = parse_url($uri, PHP_URL_HOST); + $host = mb_strtolower($host); + + if (!in_array($host, $this->allowedRemoteHosts, true)) { + return [false, "Remote host is not in allowed list: " . $host]; + } + } + return [true, null]; } } diff --git a/src/Positioner/TableCell.php b/src/Positioner/TableCell.php index 1a6ac6298..e012aaa3a 100644 --- a/src/Positioner/TableCell.php +++ b/src/Positioner/TableCell.php @@ -6,6 +6,7 @@ */ namespace Dompdf\Positioner; +use Dompdf\Exception; use Dompdf\FrameDecorator\AbstractFrameDecorator; use Dompdf\FrameDecorator\Table; @@ -23,6 +24,9 @@ class TableCell extends AbstractPositioner function position(AbstractFrameDecorator $frame): void { $table = Table::find_parent_table($frame); + if ($table === null) { + throw new Exception("Parent table not found for table cell"); + } $cellmap = $table->get_cellmap(); $frame->set_position($cellmap->get_frame_position($frame)); } diff --git a/src/Renderer.php b/src/Renderer.php index e3cacc104..15391fb63 100644 --- a/src/Renderer.php +++ b/src/Renderer.php @@ -9,8 +9,10 @@ use Dompdf\Renderer\AbstractRenderer; use Dompdf\Renderer\Block; use Dompdf\Renderer\Image; +use Dompdf\Renderer\Inline; use Dompdf\Renderer\ListBullet; use Dompdf\Renderer\TableCell; +use Dompdf\Renderer\TableRow; use Dompdf\Renderer\TableRowGroup; use Dompdf\Renderer\Text; @@ -75,18 +77,24 @@ public function render(Frame $frame) // Starts the CSS transformation if ($hasTransform) { $this->_canvas->save(); - list($x, $y) = $frame->get_padding_box(); - $origin = $style->transform_origin; + + [$x, $y] = $frame->get_padding_box(); + [$originX, $originY] = $style->transform_origin; + $w = (float) $style->length_in_pt($style->width); + $h = (float) $style->length_in_pt($style->height); foreach ($transformList as $transform) { - list($function, $values) = $transform; + [$function, $values] = $transform; + if ($function === "matrix") { $function = "transform"; + } elseif ($function === "translate") { + $values[0] = $style->length_in_pt($values[0], $w); + $values[1] = $style->length_in_pt($values[1], $h); } - $values = array_map("floatval", $values); - $values[] = $x + (float)$style->length_in_pt($origin[0], (float)$style->length_in_pt($style->width)); - $values[] = $y + (float)$style->length_in_pt($origin[1], (float)$style->length_in_pt($style->height)); + $values[] = $x + $style->length_in_pt($originX, $w); + $values[] = $y + $style->length_in_pt($originY, $h); call_user_func_array([$this->_canvas, $function], $values); } @@ -114,6 +122,10 @@ public function render(Frame $frame) $this->_render_frame("table-cell", $frame); break; + case "table-row": + $this->_render_frame("table-row", $frame); + break; + case "table-row-group": case "table-header-group": case "table-footer-group": @@ -252,7 +264,7 @@ protected function _render_frame($type, $frame) break; case "inline": - $this->_renderers[$type] = new Renderer\Inline($this->_dompdf); + $this->_renderers[$type] = new Inline($this->_dompdf); break; case "text": @@ -267,6 +279,10 @@ protected function _render_frame($type, $frame) $this->_renderers[$type] = new TableCell($this->_dompdf); break; + case "table-row": + $this->_renderers[$type] = new TableRow($this->_dompdf); + break; + case "table-row-group": $this->_renderers[$type] = new TableRowGroup($this->_dompdf); break; diff --git a/src/Renderer/AbstractRenderer.php b/src/Renderer/AbstractRenderer.php index 8b01ef8c0..0725ec4f2 100644 --- a/src/Renderer/AbstractRenderer.php +++ b/src/Renderer/AbstractRenderer.php @@ -6,12 +6,13 @@ */ namespace Dompdf\Renderer; +use DOMElement; use Dompdf\Adapter\CPDF; use Dompdf\Css\Color; use Dompdf\Css\Style; use Dompdf\Dompdf; -use Dompdf\Helpers; use Dompdf\Frame; +use Dompdf\Helpers; use Dompdf\Image\Cache; /** @@ -278,7 +279,7 @@ protected function _render_outline(Frame $frame, array $border_box, string $corn * * @throws \Exception */ - protected function _background_image($url, $x, $y, $width, $height, $style) + protected function _background_image(string $url, float $x, float $y, float $width, float $height, Style $style): void { if (!function_exists("imagecreatetruecolor")) { throw new \Exception("The PHP GD extension is required, but is not installed."); @@ -663,6 +664,83 @@ protected function _background_image($url, $x, $y, $width, $height, $style) $this->_canvas->clipping_end(); } + /** + * @param float $img_width + * @param float $img_height + * @param float $container_width + * @param float $container_height + * @param array|string $bg_resize + * @param int $dpi + * + * @return float[] + */ + protected function _resize_background_image( + float $img_width, + float $img_height, + float $container_width, + float $container_height, + $bg_resize, + int $dpi + ): array { + // We got two some specific numbers and/or auto definitions + if (is_array($bg_resize)) { + $is_auto_width = $bg_resize[0] === 'auto'; + if ($is_auto_width) { + $new_img_width = $img_width; + } else { + $new_img_width = $bg_resize[0]; + if (Helpers::is_percent($new_img_width)) { + $new_img_width = round(($container_width / 100) * (float)$new_img_width); + } else { + $new_img_width = round($new_img_width * $dpi / 72); + } + } + + $is_auto_height = $bg_resize[1] === 'auto'; + if ($is_auto_height) { + $new_img_height = $img_height; + } else { + $new_img_height = $bg_resize[1]; + if (Helpers::is_percent($new_img_height)) { + $new_img_height = round(($container_height / 100) * (float)$new_img_height); + } else { + $new_img_height = round($new_img_height * $dpi / 72); + } + } + + // if one of both was set to auto the other one needs to scale proportionally + if ($is_auto_width !== $is_auto_height) { + if ($is_auto_height) { + $new_img_height = round($new_img_width * ($img_height / $img_width)); + } else { + $new_img_width = round($new_img_height * ($img_width / $img_height)); + } + } + } else { + $container_ratio = $container_height / $container_width; + + if ($bg_resize === 'cover' || $bg_resize === 'contain') { + $img_ratio = $img_height / $img_width; + + if ( + ($bg_resize === 'cover' && $container_ratio > $img_ratio) || + ($bg_resize === 'contain' && $container_ratio < $img_ratio) + ) { + $new_img_height = $container_height; + $new_img_width = round($container_height / $img_ratio); + } else { + $new_img_width = $container_width; + $new_img_height = round($container_width * $img_ratio); + } + } else { + $new_img_width = $img_width; + $new_img_height = $img_height; + } + } + + return [$new_img_width, $new_img_height]; + } + // Border rendering functions /** @@ -1156,89 +1234,55 @@ protected function _set_opacity(float $opacity): void } /** - * @param float[] $box - * @param string $color - * @param array $style + * Add a named destination if the element has an ID or is an anchor element + * with `name` attribute. + * + * @param DOMElement $node */ - protected function _debug_layout($box, $color = "red", $style = []) + protected function addNamedDest(DOMElement $node): void { - $this->_canvas->rectangle($box[0], $box[1], $box[2], $box[3], Color::parse($color), 0.1, $style); + $id = $node->getAttribute("id"); + if ($id !== "") { + $this->_canvas->add_named_dest($id); + } + + if ($node->nodeName === "a") { + $name = $node->getAttribute("name"); + if ($name !== "") { + $this->_canvas->add_named_dest($name); + } + } } /** - * @param float $img_width - * @param float $img_height - * @param float $container_width - * @param float $container_height - * @param array|string $bg_resize - * @param int $dpi + * Add a hyperlink if the element is an anchor element with `href` + * attribute. * - * @return array + * @param DOMElement $node + * @param float[] $borderBox */ - protected function _resize_background_image( - $img_width, - $img_height, - $container_width, - $container_height, - $bg_resize, - $dpi - ) { - // We got two some specific numbers and/or auto definitions - if (is_array($bg_resize)) { - $is_auto_width = $bg_resize[0] === 'auto'; - if ($is_auto_width) { - $new_img_width = $img_width; - } else { - $new_img_width = $bg_resize[0]; - if (Helpers::is_percent($new_img_width)) { - $new_img_width = round(($container_width / 100) * (float)$new_img_width); - } else { - $new_img_width = round($new_img_width * $dpi / 72); - } - } - - $is_auto_height = $bg_resize[1] === 'auto'; - if ($is_auto_height) { - $new_img_height = $img_height; - } else { - $new_img_height = $bg_resize[1]; - if (Helpers::is_percent($new_img_height)) { - $new_img_height = round(($container_height / 100) * (float)$new_img_height); - } else { - $new_img_height = round($new_img_height * $dpi / 72); - } - } - - // if one of both was set to auto the other one needs to scale proportionally - if ($is_auto_width !== $is_auto_height) { - if ($is_auto_height) { - $new_img_height = round($new_img_width * ($img_height / $img_width)); - } else { - $new_img_width = round($new_img_height * ($img_width / $img_height)); - } - } - } else { - $container_ratio = $container_height / $container_width; - - if ($bg_resize === 'cover' || $bg_resize === 'contain') { - $img_ratio = $img_height / $img_width; - - if ( - ($bg_resize === 'cover' && $container_ratio > $img_ratio) || - ($bg_resize === 'contain' && $container_ratio < $img_ratio) - ) { - $new_img_height = $container_height; - $new_img_width = round($container_height / $img_ratio); - } else { - $new_img_width = $container_width; - $new_img_height = round($container_width * $img_ratio); - } - } else { - $new_img_width = $img_width; - $new_img_height = $img_height; - } + protected function addHyperlink(DOMElement $node, array $borderBox): void + { + if ($node->nodeName === "a" && ($href = $node->getAttribute("href")) !== "") { + [$x, $y, $w, $h] = $borderBox; + $dompdf = $this->_dompdf; + $href = Helpers::build_url( + $dompdf->getProtocol(), + $dompdf->getBaseHost(), + $dompdf->getBasePath(), + $href + ) ?? $href; + $this->_canvas->add_link($href, $x, $y, $w, $h); } + } - return [$new_img_width, $new_img_height]; + /** + * @param float[] $box + * @param array|string $color + * @param array $style + */ + protected function debugLayout(array $box, $color = "red", array $style = []): void + { + $this->_canvas->rectangle($box[0], $box[1], $box[2], $box[3], Color::parse($color), 0.1, $style); } } diff --git a/src/Renderer/Block.php b/src/Renderer/Block.php index 99db1929a..ab2768dc9 100644 --- a/src/Renderer/Block.php +++ b/src/Renderer/Block.php @@ -8,7 +8,6 @@ use Dompdf\Frame; use Dompdf\FrameDecorator\Block as BlockFrameDecorator; -use Dompdf\Helpers; /** * Renders block frames @@ -17,7 +16,6 @@ */ class Block extends AbstractRenderer { - /** * @param Frame $frame */ @@ -25,7 +23,6 @@ function render(Frame $frame) { $style = $frame->get_style(); $node = $frame->get_node(); - $dompdf = $this->_dompdf; $this->_set_opacity($frame->get_opacity($style->opacity)); @@ -45,21 +42,17 @@ function render(Frame $frame) $this->_render_border($frame, $border_box); $this->_render_outline($frame, $border_box); - // Handle anchors & links - if ($node->nodeName === "a" && $href = $node->getAttribute("href")) { - $href = Helpers::build_url($dompdf->getProtocol(), $dompdf->getBaseHost(), $dompdf->getBasePath(), $href) ?? $href; - $this->_canvas->add_link($href, $x, $y, $w, $h); - } - - $id = $frame->get_node()->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } - + $this->addNamedDest($node); + $this->addHyperlink($node, $border_box); $this->debugBlockLayout($frame, "red", false); } - protected function debugBlockLayout(Frame $frame, ?string $color, bool $lines = false): void + /** + * @param Frame $frame + * @param array|string $color + * @param bool $lines + */ + protected function debugBlockLayout(Frame $frame, $color, bool $lines = false): void { $options = $this->_dompdf->getOptions(); $debugLayout = $options->getDebugLayout(); @@ -68,11 +61,11 @@ protected function debugBlockLayout(Frame $frame, ?string $color, bool $lines = return; } - if ($color && $options->getDebugLayoutBlocks()) { - $this->_debug_layout($frame->get_border_box(), $color); + if ($options->getDebugLayoutBlocks()) { + $this->debugLayout($frame->get_border_box(), $color); if ($options->getDebugLayoutPaddingBox()) { - $this->_debug_layout($frame->get_padding_box(), $color, [0.5, 0.5]); + $this->debugLayout($frame->get_padding_box(), $color, [0.5, 0.5]); } } @@ -81,7 +74,7 @@ protected function debugBlockLayout(Frame $frame, ?string $color, bool $lines = foreach ($frame->get_line_boxes() as $line) { $lw = $cw - $line->left - $line->right; - $this->_debug_layout([$cx + $line->left, $line->y, $lw, $line->h], "orange"); + $this->debugLayout([$cx + $line->left, $line->y, $lw, $line->h], "orange"); } } } diff --git a/src/Renderer/Image.php b/src/Renderer/Image.php index 61f684f94..1373bb079 100644 --- a/src/Renderer/Image.php +++ b/src/Renderer/Image.php @@ -23,6 +23,7 @@ class Image extends Block function render(Frame $frame) { $style = $frame->get_style(); + $node = $frame->get_node(); $border_box = $frame->get_border_box(); $this->_set_opacity($frame->get_opacity($style->opacity)); @@ -36,11 +37,8 @@ function render(Frame $frame) [$x, $y, $w, $h] = $content_box; $src = $frame->get_image_url(); - $alt = null; - if (Cache::is_broken($src) && - $alt = $frame->get_node()->getAttribute("alt") - ) { + if (Cache::is_broken($src) && ($alt = $node->getAttribute("alt")) !== "") { $font = $style->font_family; $size = $style->font_size; $word_spacing = $style->word_spacing; @@ -69,22 +67,7 @@ function render(Frame $frame) } } - if ($msg = $frame->get_image_msg()) { - $parts = preg_split("/\s*\n\s*/", $msg); - $font = $style->font_family; - $height = 10; - $_y = $alt ? $y + $h - count($parts) * $height : $y; - - foreach ($parts as $i => $_part) { - $this->_canvas->text($x, $_y + $i * $height, $_part, $font, $height * 0.8, [0.5, 0.5, 0.5]); - } - } - - $id = $frame->get_node()->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } - + $this->addNamedDest($node); $this->debugBlockLayout($frame, "blue"); } } diff --git a/src/Renderer/Inline.php b/src/Renderer/Inline.php index ad3546492..5d9e284ea 100644 --- a/src/Renderer/Inline.php +++ b/src/Renderer/Inline.php @@ -7,7 +7,6 @@ namespace Dompdf\Renderer; use Dompdf\Frame; -use Dompdf\Helpers; /** * Renders inline frames @@ -18,22 +17,27 @@ class Inline extends AbstractRenderer { function render(Frame $frame) { - if (!$frame->get_first_child()) { + // Get the first in-flow child + $child = $frame->get_first_child(); + while ($child && !$child->is_in_flow()) { + $child = $child->get_next_sibling(); + } + + if (!$child) { return; // No children, no service } $style = $frame->get_style(); - $dompdf = $this->_dompdf; + $node = $frame->get_node(); $this->_set_opacity($frame->get_opacity($style->opacity)); - $do_debug_layout_line = $dompdf->getOptions()->getDebugLayout() - && $dompdf->getOptions()->getDebugLayoutInline(); - - // Draw the background & border behind each child. To do this we need - // to figure out just how much space each child takes: - [$x, $y] = $frame->get_first_child()->get_position(); - [$w, $h] = $this->get_child_size($frame, $do_debug_layout_line); + // Draw background & border behind each child. To do this, we need to + // to figure out just how much space each child takes. Retrieve the + // position of the first child again, to account for text and vertical + // alignment + [$x, $y] = $child->get_position(); + [$w, $h] = $this->get_child_size($frame); [, , $cbw] = $frame->get_containing_block(); $margin_left = $style->length_in_pt($style->margin_left, $cbw); @@ -47,7 +51,6 @@ function render(Frame $frame) // to work around the vertical position being slightly off in general $x += $margin_left; $y -= $style->border_top_width + $pt - ($h * 0.1); - $w += $style->border_left_width + $style->border_right_width; $h += $style->border_top_width + $pt + $style->border_bottom_width + $pb; $border_box = [$x, $y, $w, $h]; @@ -55,50 +58,50 @@ function render(Frame $frame) $this->_render_border($frame, $border_box); $this->_render_outline($frame, $border_box); - $node = $frame->get_node(); - $id = $node->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } + $this->addNamedDest($node); + $this->addHyperlink($node, $border_box); - // Only two levels of links frames - $is_link_node = $node->nodeName === "a"; - if ($is_link_node) { - if (($name = $node->getAttribute("name"))) { - $this->_canvas->add_named_dest($name); - } - } + $options = $this->_dompdf->getOptions(); - if ($frame->get_parent() && $frame->get_parent()->get_node()->nodeName === "a") { - $link_node = $frame->get_parent()->get_node(); - } + if ($options->getDebugLayout() && $options->getDebugLayoutInline()) { + $this->debugLayout($border_box, "blue"); - // Handle anchors & links - if ($is_link_node) { - if ($href = $node->getAttribute("href")) { - $href = Helpers::build_url($dompdf->getProtocol(), $dompdf->getBaseHost(), $dompdf->getBasePath(), $href) ?? $href; - $this->_canvas->add_link($href, $x, $y, $w, $h); + if ($options->getDebugLayoutPaddingBox()) { + $padding_box = [ + $x + $style->border_left_width, + $y + $style->border_top_width, + $w - $style->border_left_width - $style->border_right_width, + $h - $style->border_top_width - $style->border_bottom_width + ]; + $this->debugLayout($padding_box, "blue", [0.5, 0.5]); } } } - protected function get_child_size(Frame $frame, bool $do_debug_layout_line): array + protected function get_child_size(Frame $frame): array { $w = 0.0; $h = 0.0; foreach ($frame->get_children() as $child) { - if ($child->get_node()->nodeValue === " " && $child->get_prev_sibling() && !$child->get_next_sibling()) { + if (!$child->is_in_flow()) { + continue; + } + + // Exclude trailing white space + if ($child->get_node()->nodeValue === " " + && $child->get_prev_sibling() && !$child->get_next_sibling() + ) { break; } $style = $child->get_style(); $auto_width = $style->width === "auto"; $auto_height = $style->height === "auto"; - [, , $child_w, $child_h] = $child->get_padding_box(); + [, , $child_w, $child_h] = $child->get_border_box(); if ($auto_width || $auto_height) { - [$child_w2, $child_h2] = $this->get_child_size($child, $do_debug_layout_line); + [$child_w2, $child_h2] = $this->get_child_size($child); if ($auto_width) { $child_w = $child_w2; @@ -111,14 +114,6 @@ protected function get_child_size(Frame $frame, bool $do_debug_layout_line): arr $w += $child_w; $h = max($h, $child_h); - - if ($do_debug_layout_line) { - $this->_debug_layout($child->get_border_box(), "blue"); - - if ($this->_dompdf->getOptions()->getDebugLayoutPaddingBox()) { - $this->_debug_layout($child->get_padding_box(), "blue", [0.5, 0.5]); - } - } } return [$w, $h]; diff --git a/src/Renderer/ListBullet.php b/src/Renderer/ListBullet.php index 038cf87f4..78051a84d 100644 --- a/src/Renderer/ListBullet.php +++ b/src/Renderer/ListBullet.php @@ -22,6 +22,7 @@ class ListBullet extends AbstractRenderer /** * @param $type * @return mixed|string + * @deprecated */ static function get_counter_chars($type) { @@ -69,17 +70,15 @@ static function get_counter_chars($type) } /** - * @param int $n - * @param string $type + * @param int $n + * @param string $type * @param int|null $pad * * @return string */ - private function make_counter($n, $type, $pad = null) + private function make_counter(int $n, string $type, ?int $pad = null): string { - $n = intval($n); $text = ""; - $uppercase = false; switch ($type) { default: @@ -94,14 +93,18 @@ private function make_counter($n, $type, $pad = null) case "upper-alpha": case "upper-latin": - $uppercase = true; + $text = chr((($n - 1) % 26) + ord('A')); + break; + case "lower-alpha": case "lower-latin": $text = chr((($n - 1) % 26) + ord('a')); break; case "upper-roman": - $uppercase = true; + $text = strtoupper(Helpers::dec2roman($n)); + break; + case "lower-roman": $text = Helpers::dec2roman($n); break; @@ -111,10 +114,6 @@ private function make_counter($n, $type, $pad = null) break; } - if ($uppercase) { - $text = strtoupper($text); - } - return "$text."; } @@ -190,13 +189,9 @@ function render(Frame $frame) return; } - $index = $node->getAttribute("dompdf-counter"); + $index = (int) $node->getAttribute("dompdf-counter"); $text = $this->make_counter($index, $bullet_style, $pad); - if (trim($text) === "") { - return; - } - $word_spacing = $style->word_spacing; $letter_spacing = $style->letter_spacing; $text_width = $this->_dompdf->getFontMetrics()->getTextWidth($text, $font_family, $font_size, $word_spacing, $letter_spacing); @@ -208,15 +203,11 @@ function render(Frame $frame) $this->_canvas->text($x, $y, $text, $font_family, $font_size, $style->color, $word_spacing, $letter_spacing); + break; case "none": break; } } - - $id = $frame->get_node()->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } } } diff --git a/src/Renderer/TableCell.php b/src/Renderer/TableCell.php index cbbffd34c..fbf9178e2 100644 --- a/src/Renderer/TableCell.php +++ b/src/Renderer/TableCell.php @@ -6,6 +6,7 @@ */ namespace Dompdf\Renderer; +use Dompdf\Exception; use Dompdf\Frame; use Dompdf\FrameDecorator\Table; @@ -16,15 +17,15 @@ */ class TableCell extends Block { - /** * @param Frame $frame */ function render(Frame $frame) { $style = $frame->get_style(); + $node = $frame->get_node(); - if (trim($frame->get_node()->nodeValue) === "" && $style->empty_cells === "hide") { + if (trim($node->nodeValue) === "" && $style->empty_cells === "hide") { return; } @@ -32,6 +33,9 @@ function render(Frame $frame) $border_box = $frame->get_border_box(); $table = Table::find_parent_table($frame); + if ($table === null) { + throw new Exception("Parent table not found for table cell"); + } if ($table->get_style()->border_collapse !== "collapse") { $this->_render_background($frame, $border_box); @@ -58,12 +62,9 @@ function render(Frame $frame) $this->_render_outline($frame, $border_box); } - $id = $frame->get_node()->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } - - // $this->debugBlockLayout($frame, "red", false); + $this->addNamedDest($node); + $this->addHyperlink($node, $border_box); + $this->debugBlockLayout($frame, "red", false); } /** diff --git a/src/Renderer/TableRow.php b/src/Renderer/TableRow.php new file mode 100644 index 000000000..b1608e828 --- /dev/null +++ b/src/Renderer/TableRow.php @@ -0,0 +1,40 @@ +get_style(); + $node = $frame->get_node(); + + $this->_set_opacity($frame->get_opacity($style->opacity)); + + $border_box = $frame->get_border_box(); + + // FIXME: Render background onto the area consisting of all spanned + // cells. In the separated border model, the border-spacing area should + // be left out. Currently, the background is inherited by the table + // cells instead, which does not handle transparent backgrounds and + // background images correctly. + // See https://www.w3.org/TR/CSS21/tables.html#table-layers + + $this->_render_outline($frame, $border_box); + + $this->addNamedDest($node); + $this->addHyperlink($node, $border_box); + } +} diff --git a/src/Renderer/TableRowGroup.php b/src/Renderer/TableRowGroup.php index 295ccde3e..eb5d23be8 100644 --- a/src/Renderer/TableRowGroup.php +++ b/src/Renderer/TableRowGroup.php @@ -9,32 +9,32 @@ use Dompdf\Frame; /** - * Renders block frames - * * @package dompdf */ class TableRowGroup extends Block { - /** * @param Frame $frame */ function render(Frame $frame) { $style = $frame->get_style(); + $node = $frame->get_node(); $this->_set_opacity($frame->get_opacity($style->opacity)); $border_box = $frame->get_border_box(); - $this->_render_border($frame, $border_box); - $this->_render_outline($frame, $border_box); + // FIXME: Render background onto the area consisting of all spanned + // cells. In the separated border model, the border-spacing area should + // be left out. Currently, the background is inherited by the table + // cells instead, which does not handle transparent backgrounds and + // background images correctly. + // See https://www.w3.org/TR/CSS21/tables.html#table-layers - $id = $frame->get_node()->getAttribute("id"); - if (strlen($id) > 0) { - $this->_canvas->add_named_dest($id); - } + $this->_render_outline($frame, $border_box); - $this->debugBlockLayout($frame, "red"); + $this->addNamedDest($node); + $this->addHyperlink($node, $border_box); } } diff --git a/src/Renderer/Text.php b/src/Renderer/Text.php index 2a2e5cc72..656f3c943 100644 --- a/src/Renderer/Text.php +++ b/src/Renderer/Text.php @@ -51,7 +51,7 @@ function render(Frame $frame) $this->_set_opacity($frame->get_opacity($style->opacity)); - list($x, $y) = $frame->get_position(); + [$x, $y] = $frame->get_position(); $cb = $frame->get_containing_block(); $ml = $style->margin_left; @@ -150,9 +150,12 @@ function render(Frame $frame) $this->_canvas->line($x1, $deco_y, $x2, $deco_y, $color, $line_thickness); } - if ($this->_dompdf->getOptions()->getDebugLayout() && $this->_dompdf->getOptions()->getDebugLayoutLines()) { - $text_width = $this->_dompdf->getFontMetrics()->getTextWidth($text, $font, $size, $word_spacing, $letter_spacing); - $this->_debug_layout([$x, $y, $text_width, $frame_font_size], "orange", [0.5, 0.5]); + $options = $this->_dompdf->getOptions(); + + if ($options->getDebugLayout() && $options->getDebugLayoutLines()) { + $fontMetrics = $this->_dompdf->getFontMetrics(); + $textWidth = $fontMetrics->getTextWidth($text, $font, $size, $word_spacing, $letter_spacing); + $this->debugLayout([$x, $y, $textWidth, $frame_font_size], "orange", [0.5, 0.5]); } } } diff --git a/tests/Canvas/CPDFTest.php b/tests/Canvas/CPDFTest.php index 55f9bc52d..976232f17 100644 --- a/tests/Canvas/CPDFTest.php +++ b/tests/Canvas/CPDFTest.php @@ -66,4 +66,75 @@ public function testPageLine(): void $output = $canvas->output(); $this->assertNotSame("", $output); } + + public static function fontSupportsCharProvider(): array + { + return [ + // Core fonts + // ASCII and ISO-8859-1 + ["Helvetica", "A", true], + ["Helvetica", "{", true], + ["Helvetica", "Æ", true], + ["Helvetica", "÷", true], + + // Part of Windows-1252, but not ISO-8859-1 + ["Helvetica", "€", true], + ["Helvetica", "‚", true], + ["Helvetica", "ƒ", true], + ["Helvetica", "„", true], + ["Helvetica", "…", true], + ["Helvetica", "†", true], + ["Helvetica", "‡", true], + ["Helvetica", "ˆ", true], + ["Helvetica", "‰", true], + ["Helvetica", "Š", true], + ["Helvetica", "‹", true], + ["Helvetica", "Œ", true], + ["Helvetica", "Ž", true], + ["Helvetica", "‘", true], + ["Helvetica", "’", true], + ["Helvetica", "“", true], + ["Helvetica", "”", true], + ["Helvetica", "•", true], + ["Helvetica", "–", true], + ["Helvetica", "—", true], + ["Helvetica", "˜", true], + ["Helvetica", "™", true], + ["Helvetica", "š", true], + ["Helvetica", "›", true], + ["Helvetica", "œ", true], + ["Helvetica", "ž", true], + ["Helvetica", "Ÿ", true], + ["Helvetica", "ÿ", true], + + // Unicode outside Windows-1252 + ["Helvetica", "Ā", false], + ["Helvetica", "↦", false], + ["Helvetica", "∉", false], + ["Helvetica", "能", false], + + // DejaVu + ["DejaVu Sans", "A", true], + ["DejaVu Sans", "{", true], + ["DejaVu Sans", "Æ", true], + ["DejaVu Sans", "÷", true], + ["DejaVu Sans", "Œ", true], + ["DejaVu Sans", "—", true], + ["DejaVu Sans", "↦", true], + ["DejaVu Sans", "∉", true], + ["DejaVu Sans", "能", false], + ]; + } + + /** + * @dataProvider fontSupportsCharProvider + */ + public function testFontSupportsChar(string $font, string $char, bool $expected): void + { + $dompdf = new Dompdf(); + $canvas = new CPDF("letter", "portrait", $dompdf); + $fontFile = $dompdf->getFontMetrics()->getFont($font); + + $this->assertSame($expected, $canvas->font_supports_char($fontFile, $char)); + } } diff --git a/tests/Css/AttributeTranslatorTest.php b/tests/Css/AttributeTranslatorTest.php index 8b203abae..c846e475d 100644 --- a/tests/Css/AttributeTranslatorTest.php +++ b/tests/Css/AttributeTranslatorTest.php @@ -7,7 +7,7 @@ final class AttributeTranslatorTest extends TestCase { - public function attributeToStyleTranslationProvider(): array + public static function attributeToStyleTranslationProvider(): array { return [ // TODO: Heredocs can be nicely indented starting with PHP 7.3 diff --git a/tests/Css/ColorTest.php b/tests/Css/ColorTest.php index 94203a639..eaab8455a 100644 --- a/tests/Css/ColorTest.php +++ b/tests/Css/ColorTest.php @@ -6,7 +6,7 @@ class ColorTest extends TestCase { - public function validColorProvider(): array + public static function validColorProvider(): array { return [ // Color names diff --git a/tests/Css/SelectorTest.php b/tests/Css/SelectorTest.php index 7cfc5577e..5f144ca80 100644 --- a/tests/Css/SelectorTest.php +++ b/tests/Css/SelectorTest.php @@ -37,7 +37,7 @@ private function preProcess(string $selector): string return preg_replace($patterns, $replacements, $selector); } - public function selectorMatchesProvider(): array + public static function selectorMatchesProvider(): array { // Elements expected to matched by each selector are marked with the // attribute `data-match`. The optional third parameter defines whether @@ -788,7 +788,7 @@ public function testSelectorMatches( } } - public function selectorInvalidProvider(): array + public static function selectorInvalidProvider(): array { return [ // Valid but unsupported selector syntax diff --git a/tests/Css/ShorthandTest.php b/tests/Css/ShorthandTest.php index ff38b25d6..24c44dcd0 100644 --- a/tests/Css/ShorthandTest.php +++ b/tests/Css/ShorthandTest.php @@ -17,7 +17,7 @@ protected function style(): Style return new Style($sheet); } - public function marginPaddingShorthandProvider(): array + public static function marginPaddingShorthandProvider(): array { return [ ["5pt", "5pt", "5pt", "5pt", "5pt"], @@ -25,7 +25,11 @@ public function marginPaddingShorthandProvider(): array ["10% 5pt 25%", "10%", "5pt", "25%", "5pt"], ["5mm 4mm 3mm 2mm", "5mm", "4mm", "3mm", "2mm"], // Exponential notation - ["1e2% 50e-1pt 2.5e+1%", "1e2%", "50e-1pt", "2.5e+1%", "50e-1pt"] + ["1e2% 50e-1pt 2.5e+1%", "1e2%", "50e-1pt", "2.5e+1%", "50e-1pt"], + + // Calc + ["calc(50% - 10pt) 1%", "calc(50% - 10pt)", "1%", "calc(50% - 10pt)", "1%"], + ["calc( (5 * 1pt) + 0pt ) 5pt CALC((0pt + 5pt))5pt", "calc( (5 * 1pt) + 0pt )", "5pt", "CALC((0pt + 5pt))", "5pt"] ]; } @@ -103,13 +107,16 @@ protected function borderTypeShorthandTest( $this->assertSame($left, $style->get_specified("border_left_{$type}")); } - public function borderWidthShorthandProvider(): array + public static function borderWidthShorthandProvider(): array { return [ ["thin", "thin", "thin", "thin", "thin"], ["medium 1.2rem", "medium", "1.2rem", "medium", "1.2rem"], ["thick 5pt 12pc", "thick", "5pt", "12pc", "5pt"], ["5mm 4mm 3mm 2mm", "5mm", "4mm", "3mm", "2mm"], + + // Calc + ["calc(1pc - 12pt)medium", "calc(1pc - 12pt)", "medium", "calc(1pc - 12pt)", "medium"] ]; } @@ -126,7 +133,7 @@ public function testBorderWidthShorthand( $this->borderTypeShorthandTest("width", $value, $top, $right, $bottom, $left); } - public function borderStyleShorthandProvider(): array + public static function borderStyleShorthandProvider(): array { return [ ["solid", "solid", "solid", "solid", "solid"], @@ -149,7 +156,7 @@ public function testBorderStyleShorthand( $this->borderTypeShorthandTest("style", $value, $top, $right, $bottom, $left); } - public function borderColorShorthandProvider(): array + public static function borderColorShorthandProvider(): array { return [ ["transparent", "transparent", "transparent", "transparent", "transparent"], @@ -172,7 +179,7 @@ public function testBorderColorShorthand( $this->borderTypeShorthandTest("color", $value, $top, $right, $bottom, $left); } - public function borderShorthandProvider(): array + public static function borderOutlineShorthandProvider(): array { return [ ["transparent", "medium", "none", "transparent"], @@ -181,11 +188,31 @@ public function borderShorthandProvider(): array ["solid 5pt", "5pt", "solid", "currentcolor"], ["1pt solid red", "1pt", "solid", "red"], ["rgb(0, 0, 0) double 1rem", "1rem", "double", "rgb(0, 0, 0)"], - ["thin rgb(0 255 0 / 0.2) solid", "thin", "solid", "rgb(0 255 0 / 0.2)"] + ["thin rgb(0 255 0 / 0.2) solid", "thin", "solid", "rgb(0 255 0 / 0.2)"], + + // Calc + ["dotted calc((5pt + 1em)/2) #FF0000", "calc((5pt + 1em)/2)", "dotted", "#ff0000"], + ["calc( 3pt - 1px ) outset", "calc( 3pt - 1px )", "outset", "currentcolor"], + ]; + } + + public static function borderShorthandProvider(): array + { + return [ + ["blue 1mm hidden", "1mm", "hidden", "blue"] + ]; + } + + public static function outlineShorthandProvider(): array + { + return [ + ["auto 5pt", "5pt", "auto", "currentcolor"], + ["thin #000000 auto", "thin", "auto", "#000000"] ]; } /** + * @dataProvider borderOutlineShorthandProvider * @dataProvider borderShorthandProvider */ public function testBorderShorthand( @@ -206,20 +233,8 @@ public function testBorderShorthand( } } - public function outlineShorthandProvider(): array - { - return [ - ["transparent", "medium", "none", "transparent"], - ["currentcolor 1pc", "1pc", "none", "currentcolor"], - ["thick inset", "thick", "inset", "currentcolor"], - ["auto 5pt", "5pt", "auto", "currentcolor"], - ["1pt solid red", "1pt", "solid", "red"], - ["rgb(0, 0, 0) double 1rem", "1rem", "double", "rgb(0, 0, 0)"], - ["thin rgb(0 255 0 / 0.2) auto", "thin", "auto", "rgb(0 255 0 / 0.2)"] - ]; - } - /** + * @dataProvider borderOutlineShorthandProvider * @dataProvider outlineShorthandProvider */ public function testOutlineShorthand( @@ -236,13 +251,16 @@ public function testOutlineShorthand( $this->assertSame($expectedColor, $style->get_specified("outline_color")); } - public function borderRadiusShorthandProvider(): array + public static function borderRadiusShorthandProvider(): array { return [ ["5pt", "5pt", "5pt", "5pt", "5pt"], ["1rem 2rem", "1rem", "2rem", "1rem", "2rem"], ["10% 5pt 15%", "10%", "5pt", "15%", "5pt"], ["5mm 4mm 3mm 2mm", "5mm", "4mm", "3mm", "2mm"], + + // Calc + ["calc(50% - 10pt) 1%", "calc(50% - 10pt)", "1%", "calc(50% - 10pt)", "1%"], ]; } @@ -265,7 +283,7 @@ public function testBorderRadiusShorthand( $this->assertSame($bl, $style->get_specified("border_bottom_left_radius")); } - public function backgroundShorthandProvider(): array + public static function backgroundShorthandProvider(): array { $basePath = realpath(__DIR__ . "/.."); $imagePath = "$basePath/_files/jamaica.jpg"; @@ -276,7 +294,11 @@ public function backgroundShorthandProvider(): array ["url( \"$imagePath\" )", "url( \"$imagePath\" )"], ["rgba( 5, 5, 5, 1 )", "none", [0.0, 0.0], ["auto", "auto"], "repeat", "scroll", "rgba( 5, 5, 5, 1 )"], ["url(non-existing.png) top center no-repeat red fixed", "url(non-existing.png)", "top center", ["auto", "auto"], "no-repeat", "fixed", "red"], - ["url($imagePath) LEFT/200PT 30% RGB( 123 16 69/0.8 )no-REPEAT", "url($imagePath)", "left", "200pt 30%", "no-repeat", "scroll", "rgb( 123 16 69/0.8 )"] + ["url($imagePath) LEFT/200PT 30% RGB( 123 16 69/0.8 )no-REPEAT", "url($imagePath)", "left", "200pt 30%", "no-repeat", "scroll", "rgb( 123 16 69/0.8 )"], + ["url($imagePath) 10pt 10pt/200PT 30%", "url($imagePath)", "10pt 10pt", "200pt 30%"], + + // Calc for position and size + ["url($imagePath) calc(100% - 20pt)/ calc(10% + 20pt)CALC(100%/3)", "url($imagePath)", "calc(100% - 20pt)", "calc(10% + 20pt) calc(100%/3)"], ]; } @@ -303,7 +325,7 @@ public function testBackgroundShorthand( $this->assertSame($color, $style->get_specified("background_color")); } - public function fontShorthandProvider(): array + public static function fontShorthandProvider(): array { return [ ["8.5mm Helvetica", "normal", "normal", 400, "8.5mm", "normal", "helvetica"], @@ -312,7 +334,10 @@ public function fontShorthandProvider(): array ["700 normal ITALIC 15.5PT /2.1 'Courier',sans-serif", "italic", "normal", "700", "15.5pt", "2.1", "'courier',sans-serif"], ["normal normal small-caps 100.01% serif, sans-serif", "normal", "small-caps", 400, "100.01%", "normal", "serif,sans-serif"], ["normal normal normal xx-small/normal monospace", "normal", "normal", 400, "xx-small", "normal", "monospace"], - ["1 0 serif", "normal", "normal", "1", "0", "normal", "serif"] + ["1 0 serif", "normal", "normal", "1", "0", "normal", "serif"], + + // TODO: Calc for font size and line height + // ["italic 700 calc(1rem + 0.5pt)/calc(10/3) sans-serif", "italic", "normal", "700", "calc(1rem + 0.5pt)", "calc(10/3)", "sans-serif"], ]; } @@ -339,7 +364,7 @@ public function testFontShorthand( $this->assertSame($fontFamily, $style->get_specified("font_family")); } - public function listStyleShorthandProvider(): array + public static function listStyleShorthandProvider(): array { $basePath = realpath(__DIR__ . "/.."); $imagePath = "$basePath/_files/jamaica.jpg"; diff --git a/tests/Css/StyleTest.php b/tests/Css/StyleTest.php index 47eeee038..37121d8f3 100644 --- a/tests/Css/StyleTest.php +++ b/tests/Css/StyleTest.php @@ -14,12 +14,60 @@ use Dompdf\Dompdf; use Dompdf\Css\Style; use Dompdf\Css\Stylesheet; +use Dompdf\Frame; use Dompdf\Options; use Dompdf\Tests\TestCase; class StyleTest extends TestCase { - public function lengthInPtProvider(): array + public function testInitial(): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + $style->set_prop("width", "100pt"); + $this->assertSame(100.0, $style->width); + + $style->set_prop("width", "initial"); + $this->assertSame("auto", $style->width); + } + + public function testUnsetNonInherited(): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $s1 = new Style($sheet); + $s2 = new Style($sheet); + + $s1->set_prop("width", "100pt"); + $s2->set_prop("width", "200pt"); + $this->assertSame(100.0, $s1->width); + $this->assertSame(200.0, $s2->width); + + $s1->set_prop("width", "unset"); + $s1->inherit($s2); + $this->assertSame("auto", $s1->width); + } + + public function testUnsetInherited(): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $s1 = new Style($sheet); + $s2 = new Style($sheet); + + $s1->set_prop("orphans", "4"); + $s2->set_prop("orphans", "6"); + $this->assertSame(4, $s1->orphans); + $this->assertSame(6, $s2->orphans); + + $s1->set_prop("orphans", "unset"); + $s1->inherit($s2); + $this->assertSame(6, $s1->orphans); + } + + public static function lengthInPtProvider(): array { return [ ["auto", null, "auto"], @@ -32,35 +80,143 @@ public function lengthInPtProvider(): array ["15E-2pT", null, 0.15], ["1.5em", 20, 18.0], // Default font size is 12pt ["100%", null, 12.0], - ["50%", 360, 180.0] + ["50%", 360, 180.0], + + // Basic Arithmetic + ["calc(100%)", null, 12.0], + ["calc(50% - 1pt)", 200, 99.0], + ["calc(100)", null, 100.0], + ["calc(100% / 3)", 100, 33.3333, 4], + ["calc( 100pt + 50pt )", null, 150.0], // extra whitespace + ["calc( (100pt + 50pt) / 3)", null, 50.0], // parentheses + ["calc(50pt*2)", null, 100.0], // * do not require whitespace + ["calc(50%/2)", 120, 30.0], // / do not require whitespace + ["calc(10pt + -50%)", 12, 4.0], // negative value + ["CalC(10)", null, 10.0], // case-insensitive + + ["calc()", null, 0.0], // invalid - empty + ["calc(invalid)", 100, 0.0], // invalid + ["calc(5pt - x)", 100, 0.0], // invalid + ["calc((50% + 10) 1pt)", 100, 0.0], // invalid - missing op + ["calc(50% -1pt)", 100, 0.0], // invalid - missing op + ["calc((50% + 10) + 2pt))", 100, 0.0], // invalid - extra bracket + ["calc(100pt / 0)", null, 0.0], // invalid - division by zero + + // Comparison Functions + ["min(-20, 5 * 2, 8)", null, -20.0], // min function + ["max(-20, 5 * 2, 8)", null, 10.0], // max function + ["clamp(10, 15 - 7, 20)", null, 10.0], // clamp min function + ["clamp(10, 15 + 7, 20)", null, 20.0], // clamp max function + ["clamp(10, 7 * 2, 20)", null, 14.0], // clamp val function + ["clamp(20, 5, 10)", null, 20.0], // clamp min > max + ["clamp(20, 15, 10)", null, 20.0], // clamp min > max + ["clamp(20, 25, 10)", null, 20.0], // clamp min > max + + // Stepped Value Functions + ["round(up, 100%, 10%)", 100, 0.0], // Not supported + ["round(30%, 0%)", 100, 0.0], + ["round(4%, 9%)", 100, 0.0], + ["round(6%, 9%)", 100, 9.0], + ["round(13.5%, 9%)", 100, 18.0], // Default when exactly between (nearest) + ["round(15%, 9)", 100, 18.0], + ["round(5.4, 1)", null, 5.0], + ["round(5.5, 1)", null, 6.0], // Default when exactly between (nearest) + ["round(5.6, 1)", null, 6.0], + ["round(-5.4, 1)", null, -5.0], + ["round(-5.5, 1)", null, -5.0], // Default when exactly between (nearest) + ["round(-5.6, 1)", null, -6.0], + ["round(-5.5, -1)", null, -5.0], // Default when exactly between (nearest) + ["round(5.5, -1)", null, 6.0], // Default when exactly between (nearest) + ["round(0.54, 0.1)", null, 0.5, 4], + ["round(0.56, 0.1)", null, 0.6, 4], + ["mod(30, 0)", null, 0.0], + ["mod(18, 5)", null, 3.0], + ["mod(-18, 5)", null, 2.0], + ["mod(18, -5)", null, -2.0], + ["mod(-18, -5)", null, -3.0], + ["rem(30, 0)", null, 0.0], + ["rem(18, 5)", null, 3.0], + ["rem(-18, 5)", null, -3.0], + ["rem(18, -5)", null, 3.0], + ["rem(-18, -5)", null, -3.0], + + // Trigonometric Functions + ["sin(0)", null, 0.0], // sin function + ["sin(1)", null, 0.8415, 4], // sin function + ["cos(0)", null, 1.0], // cos function + ["cos(1)", null, 0.5403, 4], // cos function + ["tan(0)", null, 0.0], // tan function + ["tan(1)", null, 1.5574, 4], // tan function + ["asin(0)", null, 0.0], // asin function + ["asin(-0.2)", null, -0.2014, 4], // asin function + ["acos(1)", null, 0.0], // acos function + ["acos(-0.2)", null, 1.7722, 4], // acos function + ["atan(0)", null, 0.0], // atan function + ["atan(1)", null, 0.7854, 4], // atan function + ["atan2(0, 0)", null, 0.0], // atan2 function + ["atan2(3, 2)", null, 0.9828, 4], // atan2 function + + // Exponential Functions + ["pow(5, 2)", null, 25.0], // pow function + ["sqrt(25)", null, 5.0], // sqrt function + ["hypot(3,4)", null, 5.0], // hypot function + ["log(1)", null, 0.0], // log function + ["log(10)", null, 2.3026, 4], // log function + ["log(8, 2)", null, 3.0], // log function + ["log(625, 5)", null, 4.0], // log function + ["exp(0)", null, 1.0], // exp function + + // Sign-Related Functions + ["abs(-20)", null, 20.0], // abs function + ["sign(-20)", null, -1.0], // sign function + ["sign(5)", null, 1.0], // sign function + ["sign(0)", null, 0.0], + ["sign(100%)", 100.0, 1.0], + ["sign(100%)", -100.0, -1.0], + ["sign(-100%)", -100.0, 1.0], + + // Complex + ["calc(max(3 + abs(-20), 5 * 2, 8 + 5) + 7)", null, 30.0], + ["calc(min(5pt, 3rem) + 2pt)", null, 7.0], + + ["unknownFunc()", null, 0.0], // Unsupported func + ["calc(1 + unknownFunc(2, 3))", null, 0.0] // Unsupported func ]; } /** * @dataProvider lengthInPtProvider */ - public function testLengthInPt(string $length, ?float $ref_size, $expected): void + public function testLengthInPt(string $length, ?float $ref_size, $expected, ?int $precision = null): void { $dompdf = new Dompdf(); $sheet = new Stylesheet($dompdf); $s = new Style($sheet); $result = $s->length_in_pt($length, $ref_size); + if ($precision !== null) { + $result = round($result, $precision); + } + $this->assertSame($expected, $result); } - public function cssImageBasicProvider(): array + public static function cssImageBasicProvider(): array { return [ "no value" => ["", "none"], "keyword none" => ["none", "none"], "bare url" => ["http://example.com/test.png", "none"], "http" => ["url(http://example.com/test.png)", "http://example.com/test.png"], - "case" => ["URL(http://example.com/Test.png)", "http://example.com/Test.png"] + "case" => ["URL(http://example.com/Test.png)", "http://example.com/Test.png"], + "quoted parens" => ["url(\"http://example.com/Test(1).png\")", "http://example.com/Test(1).png"], + "escaped parens" => ["url(http://example.com/Test\(1\).png)", "http://example.com/Test(1).png"], + "quotes" => ["url(http://example.com/Test\"1\".png)", "http://example.com/Test\"1\".png"], + "escaped quotes" => ["url(\"http://example.com/Test\\\"1\\\".png\")", "http://example.com/Test\"1\".png"] ]; } - public function cssImageNoBaseHrefProvider(): array + public static function cssImageNoBaseHrefProvider(): array { $basePath = realpath(__DIR__ . "/.."); return [ @@ -69,7 +225,7 @@ public function cssImageNoBaseHrefProvider(): array ]; } - public function cssImageWithBaseHrefProvider(): array + public static function cssImageWithBaseHrefProvider(): array { $basePath = realpath(__DIR__ . "/.."); return [ @@ -78,7 +234,7 @@ public function cssImageWithBaseHrefProvider(): array ]; } - public function cssImageWithStylesheetBaseHrefProvider(): array + public static function cssImageWithStylesheetBaseHrefProvider(): array { return [ "local absolute" => ["url(/_files/jamaica.jpg)", "https://example.com/_files/jamaica.jpg"], @@ -139,7 +295,7 @@ public function testCssImageWithStylesheetBaseHref(string $value, $expected): vo $this->assertSame($expected, $s->background_image); } - public function backgroundPositionProvider(): array + public static function backgroundPositionProvider(): array { return [ // One value @@ -180,12 +336,17 @@ public function backgroundPositionProvider(): array ["23% 50pt", ["23%", 50.0]], ["50pt 23%", [50.0, "23%"]], + // Calc values + ["calc(-75% + 100pt)", ["calc(-75% + 100pt)", "50%"]], + ["calc(33% * 3 + 1%) calc(20pt + 30pt)", ["calc(33% * 3 + 1%)", 50.0]], + // Case and whitespace variations ["LEFT", [0.0, "50%"]], ["TOP Right", ["100%", 0.0]], ["-23PT BoTTom", [-23.0, "100%"]], // Invalid values + ["", [0.0, 0.0]], ["none", [0.0, 0.0]], ["auto", [0.0, 0.0]], ["left left", [0.0, 0.0]], @@ -213,7 +374,55 @@ public function testBackgroundPosition(string $value, $expected): void $this->assertSame($expected, $style->background_position); } - public function fontWeightProvider(): array + public static function backgroundSizeProvider(): array + { + return [ + // Keywords + ["cover", "cover"], + ["contain", "contain"], + + // One value + ["100%", ["100%", "auto"]], + ["200pt", [200.0, "auto"]], + + // Two values + ["100% auto", ["100%", "auto"]], + ["200pt auto", [200.0, "auto"]], + ["auto 100%", ["auto", "100%"]], + ["auto 200pt", ["auto", 200.0]], + ["10% 200pt", ["10%", 200.0]], + + // Calc values + ["calc(-75% + 100pt) auto", ["calc(-75% + 100pt)", "auto"]], + ["calc(33% * 3 + 1%) calc(20pt + 30pt)", ["calc(33% * 3 + 1%)", 50.0]], + + // Case and whitespace variations + ["CoveR", "cover"], + ["AUTO 23PT", ["auto", 23.0]], + ["CALC(20PT*3)23PT", [60.0, 23.0]], + + // Invalid values + ["", ["auto", "auto"]], + ["none", ["auto", "auto"]], + ["auto", ["auto", "auto"]], + ["cover contain", ["auto", "auto"]] + ]; + } + + /** + * @dataProvider backgroundSizeProvider + */ + public function testBackgroundSize(string $value, $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + $style->set_prop("background_size", $value); + $this->assertSame($expected, $style->background_size); + } + + public static function fontWeightProvider(): array { return [ // Absolute @@ -300,32 +509,76 @@ private function testLengthProperty( $this->assertSame($expected, $style->$prop); } - public function widthHeightProvider(): array + public static function lengthPercentagePositiveProvider(): array { return [ - // Keywords - ["auto", 12.0, "auto", 0.0], + // Lengths + ["0", 12.0, 0.0], + ["1em", 20.0, 20.0], + ["100pt", 12.0, 100.0], + ["50%", 12.0, "50%"], + + // Calc values + ["calc(6pt + 2em)", 12.0, 30.0], + ["calc(50% + 2em)", 12.0, "calc(50% + 2em)"], + ["calc(100% - 100pt)", 12.0, "calc(100% - 100pt)"], + ["calc(-100pt)", 12.0, -100.0], // Negative calc values are valid + ["calc(-50%)", 12.0, "calc(-50%)"], + // Case variations + ["1EM", 20.0, 20.0], + + // Invalid values + ["-100pt", 12.0, 79.0, 79.0], + ["-50%", 12.0, 79.0, 79.0] + ]; + } + + public static function lengthPercentageProvider(): array + { + return [ // Lengths ["0", 12.0, 0.0], ["1em", 20.0, 20.0], ["100pt", 12.0, 100.0], + ["-100pt", 12.0, -100.0], ["50%", 12.0, "50%"], + ["-50%", 12.0, "-50%"], + + // Calc values + ["calc(6pt - 2em)", 12.0, -18.0], + ["calc(50% + 2em)", 12.0, "calc(50% + 2em)"], + ["calc(100% - 100pt)", 12.0, "calc(100% - 100pt)"], + ["calc(-100pt)", 12.0, -100.0], + ["calc(-50%)", 12.0, "calc(-50%)"], + + // Case variations + ["1EM", 20.0, 20.0], + + // Invalid values + ["invalid", 12.0, 79.0, 79.0], + ["-50% + 2em", 12.0, 79.0, 79.0] + ]; + } + + public static function autoKeywordProvider(): array + { + return [ + // Keywords + ["auto", 12.0, "auto", 0.0], // Case variations ["Auto", 12.0, "auto", 0.0], ["AUTO", 12.0, "auto", 0.0], - ["1EM", 20.0, 20.0], // Invalid values - ["none", 12.0, "auto"], - ["-100pt", 12.0, "auto"], - ["-50%", 12.0, "auto"] + ["none", 12.0, 79.0, 79.0] ]; } /** - * @dataProvider widthHeightProvider + * @dataProvider autoKeywordProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testWidth(string $value, float $fontSize, $expected, $initial = "auto"): void { @@ -333,14 +586,15 @@ public function testWidth(string $value, float $fontSize, $expected, $initial = } /** - * @dataProvider widthHeightProvider + * @dataProvider autoKeywordProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testHeight(string $value, float $fontSize, $expected, $initial = "auto"): void { $this->testLengthProperty("height", $value, $fontSize, $expected, ["height" => $initial]); } - public function minWidthHeightProvider(): array + public static function minWidthHeightProvider(): array { return [ // Keywords @@ -349,25 +603,18 @@ public function minWidthHeightProvider(): array // Legacy keywords ["none", 12.0, "auto", 0.0], - // Lengths - ["0", 12.0, 0.0], - ["1em", 20.0, 20.0], - ["100pt", 12.0, 100.0], - ["50%", 12.0, "50%"], - // Case variations ["Auto", 12.0, "auto", 0.0], ["AUTO", 12.0, "auto", 0.0], - ["1EM", 20.0, 20.0], // Invalid values - ["-100pt", 12.0, "auto"], - ["-50%", 12.0, "auto"] + ["other", 12.0, 79.0, 79.0] ]; } /** * @dataProvider minWidthHeightProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testMinWidth(string $value, float $fontSize, $expected, $initial = "auto"): void { @@ -376,13 +623,14 @@ public function testMinWidth(string $value, float $fontSize, $expected, $initial /** * @dataProvider minWidthHeightProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testMinHeight(string $value, float $fontSize, $expected, $initial = "auto"): void { $this->testLengthProperty("min_height", $value, $fontSize, $expected, ["min_height" => $initial]); } - public function maxWidthHeightProvider(): array + public static function maxWidthHeightProvider(): array { return [ // Keywords @@ -391,25 +639,18 @@ public function maxWidthHeightProvider(): array // Legacy keywords ["auto", 12.0, "none", 0.0], - // Lengths - ["0", 12.0, 0.0], - ["1em", 20.0, 20.0], - ["100pt", 12.0, 100.0], - ["50%", 12.0, "50%"], - // Case variations ["None", 12.0, "none", 0.0], ["NONE", 12.0, "none", 0.0], - ["1EM", 20.0, 20.0], // Invalid values - ["-100pt", 12.0, "none"], - ["-50%", 12.0, "none"] + ["other", 12.0, 79.0, 79.0] ]; } /** * @dataProvider maxWidthHeightProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testMaxWidth(string $value, float $fontSize, $expected, $initial = "none"): void { @@ -418,13 +659,79 @@ public function testMaxWidth(string $value, float $fontSize, $expected, $initial /** * @dataProvider maxWidthHeightProvider + * @dataProvider lengthPercentagePositiveProvider */ public function testMaxHeight(string $value, float $fontSize, $expected, $initial = "none"): void { $this->testLengthProperty("max_height", $value, $fontSize, $expected, ["max_height" => $initial]); } - public function lineWidthProvider(): array + /** + * @dataProvider autoKeywordProvider + * @dataProvider lengthPercentageProvider + */ + public function testBoxInset(string $value, float $fontSize, $expected, $initial = "auto"): void + { + $this->testLengthProperty("top", $value, $fontSize, $expected, ["top" => $initial]); + $this->testLengthProperty("right", $value, $fontSize, $expected, ["right" => $initial]); + $this->testLengthProperty("bottom", $value, $fontSize, $expected, ["bottom" => $initial]); + $this->testLengthProperty("left", $value, $fontSize, $expected, ["left" => $initial]); + } + + public static function marginProvider(): array + { + return [ + // Keywords + ["auto", 12.0, "auto", 0.0], + + // Legacy keywords + ["none", 12.0, 0.0, 0.0], + + // Case variations + ["Auto", 12.0, "auto", 0.0], + ["AUTO", 12.0, "auto", 0.0], + + // Invalid values + ["other", 12.0, 79.0, 79.0] + ]; + } + + /** + * @dataProvider marginProvider + * @dataProvider lengthPercentageProvider + */ + public function testMargin(string $value, float $fontSize, $expected, $initial = "auto"): void + { + $this->testLengthProperty("margin_top", $value, $fontSize, $expected, ["margin_top" => $initial]); + $this->testLengthProperty("margin_right", $value, $fontSize, $expected, ["margin_right" => $initial]); + $this->testLengthProperty("margin_bottom", $value, $fontSize, $expected, ["margin_bottom" => $initial]); + $this->testLengthProperty("margin_left", $value, $fontSize, $expected, ["margin_left" => $initial]); + } + + public static function paddingProvider(): array + { + return [ + // Legacy keywords + ["none", 12.0, 0.0, 0.0], + + // Invalid values + ["auto", 12.0, 79.0, 79.0] + ]; + } + + /** + * @dataProvider paddingProvider + * @dataProvider lengthPercentagePositiveProvider + */ + public function testPadding(string $value, float $fontSize, $expected, $initial = "auto"): void + { + $this->testLengthProperty("padding_top", $value, $fontSize, $expected, ["padding_top" => $initial]); + $this->testLengthProperty("padding_right", $value, $fontSize, $expected, ["padding_right" => $initial]); + $this->testLengthProperty("padding_bottom", $value, $fontSize, $expected, ["padding_bottom" => $initial]); + $this->testLengthProperty("padding_left", $value, $fontSize, $expected, ["padding_left" => $initial]); + } + + public static function lineWidthProvider(): array { return [ // Keywords @@ -437,6 +744,10 @@ public function lineWidthProvider(): array ["1em", 20.0, 20.0], ["100pt", 12.0, 100.0], + // Calc values + ["calc(6pt + 2em)", 12.0, 30.0], + ["calc(-100pt)", 12.0, -100.0], // Negative calc values are valid + // Case variations ["THIN", 12.0, 0.5], ["Medium", 12.0, 1.5], @@ -448,7 +759,8 @@ public function lineWidthProvider(): array ["none", 12.0, 5.0, 5.0, 5.0], ["-100pt", 12.0, 5.0, 5.0, 5.0], ["50%", 12.0, 5.0, 5.0, 5.0], - ["-50%", 12.0, 5.0, 5.0, 5.0] + ["-50%", 12.0, 5.0, 5.0, 5.0], + ["calc(50% + 2em)", 12.0, 5.0, 5.0, 5.0] ]; } @@ -479,7 +791,67 @@ public function testBorderOutlineWidth( } } - public function counterIncrementProvider(): array + public static function borderRadiusProvider(): array + { + return [ + // Invalid values + ["auto", 12.0, 79.0, 79.0], + ["none", 12.0, 79.0, 79.0] + ]; + } + + /** + * @dataProvider borderRadiusProvider + * @dataProvider lengthPercentagePositiveProvider + */ + public function testBorderRadius(string $value, float $fontSize, $expected, $initial = "auto"): void + { + $this->testLengthProperty("border_top_left_radius", $value, $fontSize, $expected, ["border_top_left_radius" => $initial]); + $this->testLengthProperty("border_top_right_radius", $value, $fontSize, $expected, ["border_top_right_radius" => $initial]); + $this->testLengthProperty("border_bottom_right_radius", $value, $fontSize, $expected, ["border_bottom_right_radius" => $initial]); + $this->testLengthProperty("border_bottom_left_radius", $value, $fontSize, $expected, ["border_bottom_left_radius" => $initial]); + } + + public static function borderSpacingProvider(): array + { + return [ + // One value + ["0", [0.0, 0.0]], + ["10pt", [10.0, 10.0]], + + // Two values + ["0 0", [0.0, 0.0]], + ["20pt 50pt", [20.0, 50.0]], + + // Calc values + ["20pt calc(20pt + 30pt)", [20.0, 50.0]], + + // Case and whitespace variations + ["CALC(20PT*3)23PT", [60.0, 23.0]], + + // Invalid values + ["", [0.0, 0.0]], + ["none", [0.0, 0.0]], + ["auto", [0.0, 0.0]], + ["100% 10pt", [0.0, 0.0]], + ["30pt -10pt", [0.0, 0.0]] + ]; + } + + /** + * @dataProvider borderSpacingProvider + */ + public function testBorderSpacing(string $value, $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + $style->set_prop("border_spacing", $value); + $this->assertSame($expected, $style->border_spacing); + } + + public static function counterIncrementProvider(): array { return [ // Keywords @@ -530,7 +902,7 @@ public function testCounterIncrement(string $value, $expected): void $this->assertSame($expected, $style->counter_increment); } - public function counterResetProvider(): array + public static function counterResetProvider(): array { return [ // Keywords @@ -581,7 +953,7 @@ public function testCounterReset(string $value, $expected): void $this->assertSame($expected, $style->counter_reset); } - public function quotesProvider(): array + public static function quotesProvider(): array { $autoResolved = [['"', '"'], ["'", "'"]]; @@ -621,7 +993,7 @@ public function testQuotes(string $value, $expected): void $this->assertSame($expected, $style->quotes); } - public function contentProvider(): array + public static function contentProvider(): array { return [ // Keywords @@ -632,6 +1004,7 @@ public function contentProvider(): array ['"string"', [new StringPart("string")]], ["'string'", [new StringPart("string")]], ["\"'s't\\\"r'\"", [new StringPart("'s't\"r'")]], + ['"\(\A\5F\;_\A\)"', [new StringPart("(\n_;_\n)")]], ["'attr(title)'", [new StringPart("attr(title)")]], ['"url(\'image.png\')"', [new StringPart("url('image.png')")]], @@ -643,12 +1016,16 @@ public function contentProvider(): array ['url("image.png")', [new Url("image.png")]], ["url('image.png')", [new Url("image.png")]], ["url(\"'image.PNG'\")", [new Url("'image.PNG'")]], + ["url(\"image(1).PNG\")", [new Url("image(1).PNG")]], + ["url(image\(1\).PNG)", [new Url("image(1).PNG")]], + ["url(\"image\\\"1\\\".PNG\")", [new Url("image\"1\".PNG")]], // Counter/Counters ["counter(c)", [new Counter("c", "decimal")]], ["counter(UPPER, UPPER-roman)", [new Counter("UPPER", "upper-roman")]], ["counters(c, '')", [new Counters("c", "", "decimal")]], ["counters(c, '', decimal)", [new Counters("c", "", "decimal")]], + ["counters(c, ')', decimal)", [new Counters("c", ")", "decimal")]], ["counters(UPPER, 'UPPER', lower-ROMAN)", [new Counters("UPPER", "UPPER", "lower-roman")]], // Quotes @@ -751,7 +1128,7 @@ public function testContent(string $value, $expected): void } } - public function sizeProvider(): array + public static function sizeProvider(): array { return [ // Keywords @@ -801,7 +1178,161 @@ public function testSize(string $value, $expected): void $this->assertSame($expected, $style->size); } - public function opacityProvider(): array + public static function transformProvider(): array + { + $initialInvalid = [["translate", 0.0, 0.0]]; + + return [ + // Keywords + ["none", []], + + // Translate + ["translate(10pt)", [["translate", [10.0, 0.0]]]], + ["translate(10pt, 5pt)", [["translate", [10.0, 5.0]]]], + ["translate(100%, -50%)", [["translate", ["100%", "-50%"]]]], + ["translateX(10pt)", [["translate", [10.0, 0.0]]]], + ["translateY(10pt)", [["translate", [0.0, 10.0]]]], + + // Scale + ["scale(2.5)", [["scale", [2.5, 2.5]]]], + ["scale(5, 1)", [["scale", [5.0, 1.0]]]], + ["scale(-5, 0)", [["scale", [-5.0, 0.0]]]], + ["scaleX(5)", [["scale", [5.0, 1.0]]]], + ["scaleY(5)", [["scale", [1.0, 5.0]]]], + + // Rotate + ["rotate(0.0)", [["rotate", [0.0]]]], + ["rotate(0deg)", [["rotate", [0.0]]]], + ["rotate(360deg)", [["rotate", [360.0]]]], + ["rotate(-45deg)", [["rotate", [-45.0]]]], + ["rotate(-200grad)", [["rotate", [-180.0]]]], + ["rotate(0rad)", [["rotate", [0.0]]]], + ["rotate(0.25turn)", [["rotate", [90.0]]]], + + // Skew + ["skew(45deg)", [["skew", [45.0, 0.0]]]], + ["skew(45deg, 45deg)", [["skew", [45.0, 45.0]]]], + ["skewX(45deg)", [["skew", [45.0, 0.0]]]], + ["skewY(45deg)", [["skew", [0.0, 45.0]]]], + + // Transform list and calc values + ["translateX(10pt) translateX(-10pt)", [["translate", [10.0, 0.0]], ["translate", [-10.0, 0.0]]]], + ["scale(2.5) translate(calc(100% - 100pt), 100pt) rotate(-90deg)", [["scale", [2.5, 2.5]], ["translate", ["calc(100% - 100pt)", 100.0]], ["rotate", [-90.0]]]], + + // Case and whitespace variations + ["translatex(10pt)", [["translate", [10.0, 0.0]]]], + ["SCALE(2.5)TRANSLATEy(CALc(-10pt))", [["scale", [2.5, 2.5]], ["translate", [0.0, -10.0]]]], + + // Invalid values + ["auto", $initialInvalid, $initialInvalid], + ["translate( )", $initialInvalid, $initialInvalid], + ["scale(1, 1, 1)", $initialInvalid, $initialInvalid], + ["rotate(20deg, 30deg) ", $initialInvalid, $initialInvalid], + ["rotate(20deg) skewY(45deg, 90deg)", $initialInvalid, $initialInvalid], + ]; + } + + /** + * @dataProvider transformProvider + */ + public function testTransform(string $value, $expected, array $initial = []): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + $style->transform = $initial; + $style->set_prop("transform", $value); + $this->assertSame($expected, $style->transform); + } + + public static function transformOriginProvider(): array + { + return [ + // One value + ["left", [0.0, "50%", 0.0]], + ["right", ["100%", "50%", 0.0]], + ["top", ["50%", 0.0, 0.0]], + ["bottom", ["50%", "100%", 0.0]], + ["center", ["50%", "50%", 0.0]], + ["20pt", [20.0, "50%", 0.0]], + ["-10pt", [-10.0, "50%", 0.0]], + ["23%", ["23%", "50%", 0.0]], + ["-75%", ["-75%", "50%", 0.0]], + + // Two values + ["left top", [0.0, 0.0, 0.0]], + ["top left", [0.0, 0.0, 0.0]], + ["left bottom", [0.0, "100%", 0.0]], + ["bottom left", [0.0, "100%", 0.0]], + ["left center", [0.0, "50%", 0.0]], + ["center left", [0.0, "50%", 0.0]], + ["right top", ["100%", 0.0, 0.0]], + ["top right", ["100%", 0.0, 0.0]], + ["right bottom", ["100%", "100%", 0.0]], + ["bottom right", ["100%", "100%", 0.0]], + ["right center", ["100%", "50%", 0.0]], + ["center right", ["100%", "50%", 0.0]], + ["bottom center", ["50%", "100%", 0.0]], + ["center bottom", ["50%", "100%", 0.0]], + ["top center", ["50%", 0.0, 0.0]], + ["center top", ["50%", 0.0, 0.0]], + ["center center", ["50%", "50%", 0.0]], + ["left 23%", [0.0, "23%", 0.0]], + ["right 23%", ["100%", "23%", 0.0]], + ["center 23%", ["50%", "23%", 0.0]], + ["23% top", ["23%", 0.0, 0.0]], + ["23% bottom", ["23%", "100%", 0.0]], + ["23% center", ["23%", "50%", 0.0]], + ["23% 50pt", ["23%", 50.0, 0.0]], + ["50pt 23%", [50.0, "23%", 0.0]], + + // Three values + ["left top 20pt", [0.0, 0.0, 20.0]], + ["center bottom 0", ["50%", "100%", 0.0]], + ["center center -50pt", ["50%", "50%", -50.0]], + ["-50pt -23% -50pt", [-50.0, "-23%", -50.0]], + + // Calc values + ["calc(-75% + 100pt)", ["calc(-75% + 100pt)", "50%", 0.0]], + ["calc(33% * 3 + 1%) calc(20pt + 30pt) calc( 99pt/3 )", ["calc(33% * 3 + 1%)", 50.0, 33.0]], + + // Case and whitespace variations + ["LEFT", [0.0, "50%", 0.0]], + ["TOP Right", ["100%", 0.0, 0.0]], + ["-23PT BoTTom", [-23.0, "100%", 0.0]], + + // Invalid values + ["", ["50%", "50%", 0.0]], + ["none", ["50%", "50%", 0.0]], + ["auto", ["50%", "50%", 0.0]], + ["left left", ["50%", "50%", 0.0]], + ["left right", ["50%", "50%", 0.0]], + ["bottom top", ["50%", "50%", 0.0]], + ["center center center", ["50%", "50%", 0.0]], + ["1pt 2pt 3pt 4pt", ["50%", "50%", 0.0]], + ["23% left", ["50%", "50%", 0.0]], + ["23% right", ["50%", "50%", 0.0]], + ["top 23%", ["50%", "50%", 0.0]], + ["bottom 23%", ["50%", "50%", 0.0]], + ["-50pt -23% -23%", ["50%", "50%", 0.0]] // Percentage for z not allowed + ]; + } + + /** + * @dataProvider transformOriginProvider + */ + public function testTransformOrigin(string $value, $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + $style->set_prop("transform_origin", $value); + $this->assertSame($expected, $style->transform_origin); + } + + public static function opacityProvider(): array { return [ // Valid values @@ -841,7 +1372,7 @@ public function testOpacity(string $value, $expected): void $this->assertSame($expected, $style->opacity); } - public function zIndexProvider(): array + public static function zIndexProvider(): array { return [ // Valid values @@ -886,4 +1417,303 @@ public function testWordBreakBreakWord(): void $this->assertSame("normal", $style->word_break); $this->assertSame("anywhere", $style->overflow_wrap); } + + public static function varValueProvider(): array { + return [ + 'simple' => [[ + "font_family" => "var(--font-family)", + "--font-family" => "Helvetica", + ], "font_family", "Helvetica"], + + 'simple_valid_value' => [[ + "font_family" => "var(--font-family, Courier)", + "--font-family" => "Helvetica" + ], "font_family", "Helvetica"], + + 'simple_empty_value' => [[ + "font_family" => "var(--font-family, Courier)", + "--font-family" => "" + ], "font_family", "Courier"], + + 'simple_invalid_value' => [[ + "font_family" => "var(--invalid-prop, Courier)", + "--font-family" => "" + ], "font_family", "Courier"], + + 'var_value' => [[ + "border" => "2px solid var(--bg)", + "--bg" => "#ff0000FF", + ], "border_top_color", "#ff0000FF"], + + 'var_value_twice' => [[ + "border" => "2px solid var(--bg)", + "--bg" => "#ff0000FF", + "--bg" => "#0000ffFF", + ], "border_top_color", "#0000ffFF"], + + 'multi_var_value_color' => [[ + "border" => "2px var(--style) var(--bg)", + "--style" => "dotted", + "--bg" => "#ff0000FF", + ], "border_top_color", "#ff0000FF"], + + 'multi_var_value_style' => [[ + "border" => "2px var(--style) var(--bg)", + "--style" => "dotted", + "--bg" => "#ff0000FF", + ], "border_top_style", "dotted"], + + 'shorthand_override' => [[ + "border" => "2px solid var(--bg)", + "border-color" => "#0000ffff", + "--bg" => "#ff0000FF", + ], "border_top_color", "#0000ffFF"], + + 'specific_override' => [[ + "border-color" => "#0000ffff", + "border" => "2px solid var(--bg)", + "--bg" => "#ff0000FF", + ], "border_top_color", "#ff0000FF"], + + 'referenced_var' => [[ + "border" => "var(--border-specification)", + "--border-specification" => "2px solid var(--bg)", + "--bg" => "#ff0000FF", + ], "border_top_color", "#ff0000FF"], + + 'fallback_var_valid_property' => [[ + "background_color" => "var(--bg, var(--fallback))", + "--bg" => "#ffffffFF", + "--fallback" => "#000000FF", + ], "background_color", "#ffffffFF"], + + 'fallback_var_undefined_property' => [[ + "background_color" => "var(--undefined, var(--fallback))", + "--fallback" => "#000000FF", + ], "background_color", "#000000FF"], + + 'fallback_var_double_undefined_property' => [[ + "background_color" => "var(--undefined, var(--undefined, #eeeeeeFF))", + ], "background_color", "#eeeeeeFF"], + + 'recursion' => [[ + "color" => "var(--one)", + "--one" => "var(--one)" + ], "color", "#000000FF"], + + 'recursion_with_fallback' => [[ + "color" => "var(--one)", + "--one" => "var(--one, #00ff00ff)" + ], "color", "#00ff00FF"], + + 'recursion_with_recursive fallback' => [[ + "color" => "var(--one)", + "--one" => "var(--one, var(--one))" + ], "color", "#000000FF"], + ]; + } + + /** + * @dataProvider varValueProvider + */ + public function testVar(array $properties, $lookup_property, $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $style = new Style($sheet); + + // Set all properties and values. + foreach ($properties as $property => $value) { + $style->set_prop($property, $value); + } + + // Use __get to get the computed value. + $resolved_value = $style->$lookup_property; + + // Only compare the hex value from color arrays. + if (is_array($resolved_value) && array_key_exists("hex", $resolved_value)) { + $resolved_value = $resolved_value["hex"]; + } + + // Assert the parsed result. + $this->assertStringContainsString($expected, $resolved_value); + } + + public static function mergedVarValueProvider(): array { + return [ + 'simple' => [[ + [ + "color" => "var(--color)", + "--color" => "#ff0000FF" + ], + [ + "--color" => "#0000ffFF" + ], + ], "color", "#0000ffFF"], + + 'important_ref' => [[ + [ + "color" => "var(--color) !important", + "--color" => "#ff0000FF" + ], + [ + "color" => "000000FF", + "--color" => "#0000ffFF" + ], + ], "color", "#0000ffFF"], + + 'important_var' => [[ + [ + "color" => "var(--color)", + "--color" => "#ff0000FF !important" + ], + [ + "--color" => "#0000ffFF" + ], + ], "color", "#ff0000FF"], + ]; + } + + /** + * @dataProvider mergedVarValueProvider + */ + public function testMergeVar(array $styleDefs, $lookup_property, $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $styles = [ + new Style($sheet), + new Style($sheet) + ]; + + // Set all properties and values. + foreach ($styleDefs as $index => $def) { + foreach ($def as $prop => $value) { + $important = false; + if (substr($value, -9) === 'important') { + $value_tmp = rtrim(substr($value, 0, -9)); + + if (substr($value_tmp, -1) === '!') { + $value = rtrim(substr($value_tmp, 0, -1)); + $important = true; + } + } + $styles[$index]->set_prop($prop, $value, $important); + } + } + + $resolved_style = new Style($sheet); + foreach ($styles as $style) { + $resolved_style->merge($style); + } + + // Use __get to get the computed value. + $resolved_value = $resolved_style->$lookup_property; + + // Only compare the hex value from color arrays. + if (is_array($resolved_value) && array_key_exists("hex", $resolved_value)) { + $resolved_value = $resolved_value["hex"]; + } + + // Assert the parsed result. + $this->assertStringContainsString($expected, $resolved_value); + } + + public static function inheritedVarValueProvider(): array { + return [ + 'outer' => ['outer', '#0000ffFF'], + 'middle1' => ['middle1', '#00ff00FF'], + 'middle2' => ['middle2', '#ffff00FF'], + 'inner' => ['inner', '#ff0000FF'], + 'fallback' => ['fallback', '#ff00ffFF'], + 'undefined' => ['undefined', 'transparent'], + 'undefined-inherit' => ['undefined-inherit', 'transparent'], + 'inherit' => ['inherit', '#ffffffFF'], + 'invalid-inherit' => ['invalid-inherit', 'transparent'], + ]; + } + + /** + * @dataProvider inheritedVarValueProvider + */ + public function testInheritedVar($id, $hexval): void + { + $html = ' + + + + + +
+
+
+
+
+
+
+
+
+
+
+ +'; + + $styles = []; + + $dompdf = new Dompdf(); + + $dompdf->setCallbacks(['test' => [ + 'event' => 'end_frame', + 'f' => function (Frame $frame) use (&$styles) { + $node = $frame->get_node(); + if ($node->nodeName === 'div') { + $htmlid = $node->hasAttributes() && ($id = $node->attributes->getNamedItem("id")) !== null ? $id->nodeValue : $frame->get_id(); + $background_color = $frame->get_style()->background_color; + $styles[$htmlid] = is_array($background_color) ? $background_color['hex'] : $background_color; + } + } + ]]); + + $dompdf->loadHtml($html); + $dompdf->render(); + + // Todo: Ideally have the style associated with the div id or something. + $this->assertEquals($hexval, $styles[$id]); + } } diff --git a/tests/Css/StylesheetTest.php b/tests/Css/StylesheetTest.php new file mode 100644 index 000000000..a5ffa75e7 --- /dev/null +++ b/tests/Css/StylesheetTest.php @@ -0,0 +1,77 @@ + [ + << [[ + "counter_increment" => "c", + "content" => '")"' + ]] + ] + ], + "semicolon in url" => [ + << [[ + "background_image" => "url(image;\(12\).png)" + ]] + ] + ] + ]; + } + + /** + * The expected styles define the selectors to check. For each selector, the + * styles have to match the defined properties in their specified values. + * + * @dataProvider parseCssProvider + */ + public function testParseCss(string $css, array $expected): void + { + $dompdf = new Dompdf(); + $sheet = new Stylesheet($dompdf); + $sheet->load_css($css); + + $styles = $sheet->get_styles(); + $actual = []; + + foreach ($expected as $selector => $expectedStyles) { + $this->assertArrayHasKey($selector, $styles); + $this->assertSameSize($expectedStyles, $styles[$selector]); + + $actual[$selector] = array_map(function (array $props, Style $style) { + $propNames = array_keys($props); + $values = array_map(function (string $prop) use ($style) { + return $style->get_specified($prop); + }, $propNames); + + return array_combine($propNames, $values); + }, $expectedStyles, $styles[$selector]); + } + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/DompdfTest.php b/tests/DompdfTest.php index 36a4acf0b..8b9a50c57 100644 --- a/tests/DompdfTest.php +++ b/tests/DompdfTest.php @@ -57,15 +57,118 @@ public function testSetters() $this->assertIsResource($dompdf->getHttpContext()); } - public function testLoadHtml() + public static function loadHtmlProvider(): array { + $textContent = "Some – Unicode"; + $document = function (string $encoding, string $head = "") use ($textContent) { + $html = "$head$textContent"; + return $encoding !== "UTF-8" + ? mb_convert_encoding($html, $encoding, "UTF-8") + : $html; + }; + $metaCharset = function (string $charset) { + return ""; + }; + $metaContent1 = function (string $charset) { + return ""; + }; + $metaContent2 = function (string $charset) { + return ""; + }; + + return [ + // Without encoding parameter + "utf-8 no encoding" => [ + $document("UTF-8"), + null, + $textContent + ], + "utf-8 meta no encoding" => [ + $document("UTF-8", $metaCharset("UTF-8")), + null, + $textContent + ], + "windows-1252 meta no encoding 1" => [ + $document("Windows-1252", $metaCharset("Windows-1252")), + null, + $textContent + ], + "windows-1252 meta no encoding 2" => [ + $document("Windows-1252", $metaContent1("Windows-1252")), + null, + $textContent + ], + "windows-1252 meta no encoding 3" => [ + $document("Windows-1252", $metaContent2("Windows-1252")), + null, + $textContent + ], + + // With encoding parameter + "utf-8 with encoding" => [ + $document("UTF-8"), + "UTF-8", + $textContent + ], + "windows-1252 with encoding" => [ + $document("Windows-1252"), + "Windows-1252", + $textContent + ], + // Verify that passed encoding takes precedence + "windows-1252 meta mismatch with encoding" => [ + $document("Windows-1252", $metaCharset("UTF-8")), + "Windows-1252", + $textContent + ], + "utf-16 meta with encoding" => [ + $document("UTF-16", $metaCharset("UTF-16")), + "UTF-16", + $textContent + ], + + // With BOM + "utf-8 bom" => [ + "\xEF\xBB\xBF" . $document("UTF-8"), + null, + $textContent + ], + "utf-16be bom" => [ + "\xFE\xFF" . $document("UTF-16BE", $metaCharset("UTF-16")), + null, + $textContent + ], + "utf-16le bom" => [ + "\xFF\xFE" . $document("UTF-16LE", $metaCharset("UTF-16")), + null, + $textContent + ], + // Verify that BOM takes precedence + "utf-8 bom with encoding mismatch" => [ + "\xEF\xBB\xBF" . $document("UTF-8"), + "Windows-1252", + $textContent + ], + "utf-16le bom with encoding mismatch" => [ + "\xFF\xFE" . $document("UTF-16LE", $metaCharset("UTF-16")), + "UTF-8", + $textContent + ], + ]; + } + + /** + * @dataProvider loadHtmlProvider + */ + public function testLoadHtml( + string $html, + ?string $encoding, + string $expectedText + ): void { $dompdf = new Dompdf(); - $dompdf->loadHtml('Hello'); - $this->assertEquals('Hello', $dompdf->getDom()->textContent); + $dompdf->loadHtml($html, $encoding); - //Test when encoding parameter is used - $dompdf->loadHtml(mb_convert_encoding('Hello', 'windows-1252'), 'windows-1252'); - $this->assertEquals('Hello', $dompdf->getDom()->textContent); + $this->assertSame($expectedText, $dompdf->getDom()->textContent); } public function testRender() @@ -77,7 +180,7 @@ public function testRender() $this->assertEquals('', $dompdf->getDom()->textContent); } - public function callbacksProvider(): array + public static function callbacksProvider(): array { return [ ["begin_page_reflow", 1], @@ -138,7 +241,7 @@ public function testEndDocumentCallback(): void $this->assertSame(2, $called); } - public function customCanvasProvider(): array + public static function customCanvasProvider(): array { return [ ["A4", "portrait", true, "auto"], diff --git a/tests/GeneratedContentTest.php b/tests/GeneratedContentTest.php index e944b744a..d4b9ccba1 100644 --- a/tests/GeneratedContentTest.php +++ b/tests/GeneratedContentTest.php @@ -7,7 +7,7 @@ final class GeneratedContentTest extends TestCase { - public function countersProvider(): array + public static function countersProvider(): array { return [ // TODO: Heredocs can be nicely indented starting with PHP 7.3 @@ -534,7 +534,56 @@ public function countersProvider(): array "B1 0 1 1.0" ] ] - ] + ], + + // Involving page breaks + // Check that generated content is handled correctly after a page + // break if font mapping forces a text-frame split + "font mapping with page break" => [ + << + + + + + + +
+
+
+
+ + +HTML +, + [ + "div" => [ + "Box ∉ 1", + "Box ∉ 2", + "Box ∉ 3", + "Box ∉ 4" + ] + ] + ], ]; } diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php index e69f2143b..a166bdf4f 100644 --- a/tests/HelpersTest.php +++ b/tests/HelpersTest.php @@ -6,6 +6,23 @@ class HelpersTest extends TestCase { + public static function uriEncodingProvider(): array + { + return [ + ["https://example.com/test.html", "https://example.com/test.html"], + ["https://example.com?a[]=1&b%5B%5D=1&c=d+e&f=g h&i=j%2Bk%26l", "https://example.com?a%5B%5D=1&b%5B%5D=1&c=d+e&f=g%20h&i=j%2Bk%26l"], + ]; + } + + /** + * @dataProvider uriEncodingProvider + */ + public function testUriEncoding(string $uri, string $expected): void + { + $encodedUri = Helpers::encodeURI($uri); + $this->assertEquals($expected, $encodedUri); + } + public function testParseDataUriBase64Image(): void { $imageParts = [ @@ -21,7 +38,7 @@ public function testParseDataUriBase64Image(): void ); } - public function dec2RomanProvider(): array + public static function dec2RomanProvider(): array { return [ [-5, "-5"], @@ -43,7 +60,7 @@ public function testDec2Roman($number, string $expected): void $this->assertSame($expected, $roman); } - public function lengthEqualProvider(): array + public static function lengthEqualProvider(): array { // Adapted from // https://floating-point-gui.de/errors/NearlyEqualsTest.java diff --git a/tests/LayoutTest/ImageTest.php b/tests/LayoutTest/ImageTest.php index 7d9353a65..d9a241da9 100644 --- a/tests/LayoutTest/ImageTest.php +++ b/tests/LayoutTest/ImageTest.php @@ -9,7 +9,7 @@ class ImageTest extends TestCase { - public function imageDimensionsProvider(): array + public static function imageDimensionsProvider(): array { $filepath = "../_files/jamaica.jpg"; $dpiFactor = 72 / 96; diff --git a/tests/LayoutTest/PageTest.php b/tests/LayoutTest/PageTest.php index f8925d02c..72d922560 100644 --- a/tests/LayoutTest/PageTest.php +++ b/tests/LayoutTest/PageTest.php @@ -10,7 +10,7 @@ class PageTest extends TestCase { - public function pageBreakProvider(): array + public static function pageBreakProvider(): array { return [ // TODO: Heredocs can be nicely indented starting with PHP 7.3 diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 7eae50a73..f31af45b7 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -32,6 +32,7 @@ public function testConstructor() $this->assertTrue($option->getDebugLayoutBlocks()); $this->assertTrue($option->getDebugLayoutInline()); $this->assertTrue($option->getDebugLayoutPaddingBox()); + $this->assertNull($option->getAllowedRemoteHosts()); $option = new Options(['tempDir' => 'test1']); $this->assertEquals('test1', $option->getTempDir()); @@ -53,6 +54,7 @@ public function testSetters() 'fontHeightRatio' => 1.2, 'isPhpEnabled' => true, 'isRemoteEnabled' => true, + 'allowedRemoteHosts' => ['w3.org'], 'isJavascriptEnabled' => false, 'isHtml5ParserEnabled' => true, 'isFontSubsettingEnabled' => false, @@ -89,6 +91,7 @@ public function testSetters() $this->assertFalse($option->getDebugLayoutInline()); $this->assertFalse($option->getDebugLayoutPaddingBox()); $this->assertIsResource($option->getHttpContext()); + $this->assertIsArray($option->getAllowedRemoteHosts()); $option->setChroot(['test11']); $this->assertEquals(['test11'], $option->getChroot()); @@ -133,4 +136,30 @@ function ($uri) { return [true, null]; } [$validation_result] = $allowedProtocols["mock://"]["rules"][0]("mock://example.com/"); $this->assertTrue($validation_result); } + + public function testAllowedRemoteHosts() + { + $options = new Options(['isRemoteEnabled' => true]); + $options->setAllowedRemoteHosts(['en.wikipedia.org']); + $options->setAllowedProtocols(["http://"]); + $allowedRemoteHosts = $options->getAllowedRemoteHosts(); + $this->assertIsArray($allowedRemoteHosts); + $this->assertEquals(1, count($allowedRemoteHosts)); + $this->assertContains("en.wikipedia.org", $allowedRemoteHosts); + + $allowedProtocols = $options->getAllowedProtocols(); + $this->assertIsArray($allowedProtocols); + $this->assertEquals(1, count($allowedProtocols)); + $this->assertArrayHasKey("http://", $allowedProtocols); + $this->assertIsArray($allowedProtocols["http://"]); + $this->assertArrayHasKey("rules", $allowedProtocols["http://"]); + $this->assertIsArray($allowedProtocols["http://"]["rules"]); + $this->assertEquals(1, count($allowedProtocols["http://"]["rules"])); + $this->assertEquals([$options, "validateRemoteUri"], $allowedProtocols["http://"]["rules"][0]); + + [$validation_result] = $allowedProtocols["http://"]["rules"][0]("http://example.com/"); + $this->assertFalse($validation_result); + [$validation_result] = $allowedProtocols["http://"]["rules"][0]("http://en.wikipedia.org/"); + $this->assertTrue($validation_result); + } } diff --git a/tests/Renderer/RendererTest.php b/tests/Renderer/RendererTest.php index d2abf78d2..b189411b1 100644 --- a/tests/Renderer/RendererTest.php +++ b/tests/Renderer/RendererTest.php @@ -8,7 +8,7 @@ class RendererTest extends TestCase { - /** @var Renderer */ + /** @var Renderer */ private $renderer; /** @var \ReflectionMethod */ @@ -22,7 +22,7 @@ public function setUp() : void } /** - * @dataProvider providerTestResizeBackgroundImage + * @dataProvider resizeBackgroundImageProvider */ public function testResizeBackgroundImage( $img_width, @@ -48,7 +48,7 @@ public function testResizeBackgroundImage( $this->assertEquals([$new_img_width, $new_img_height], $result); } - public function providerTestResizeBackgroundImage() + public static function resizeBackgroundImageProvider(): array { return [ "cover scale up" => [100.0, 200.0, 400.0, 300.0, "cover", 400.0, 800.0], diff --git a/tests/_files/OutputTest/image/alt.html b/tests/_files/OutputTest/image/alt.html new file mode 100644 index 000000000..120cbc66b --- /dev/null +++ b/tests/_files/OutputTest/image/alt.html @@ -0,0 +1,36 @@ + + + + + + + + + +
+ Alternate text + + + diff --git a/tests/_files/OutputTest/image/alt.pdf b/tests/_files/OutputTest/image/alt.pdf new file mode 100644 index 000000000..5af575b59 Binary files /dev/null and b/tests/_files/OutputTest/image/alt.pdf differ diff --git a/tests/_files/OutputTest/image/image.html b/tests/_files/OutputTest/image/image.html new file mode 100644 index 000000000..5b74153e1 --- /dev/null +++ b/tests/_files/OutputTest/image/image.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/tests/_files/OutputTest/image/image.pdf b/tests/_files/OutputTest/image/image.pdf new file mode 100644 index 000000000..d34c79fa1 Binary files /dev/null and b/tests/_files/OutputTest/image/image.pdf differ diff --git a/tests/_files/OutputTest/inline/absolute-child.html b/tests/_files/OutputTest/inline/absolute-child.html new file mode 100644 index 000000000..8e51831fd --- /dev/null +++ b/tests/_files/OutputTest/inline/absolute-child.html @@ -0,0 +1,47 @@ + + + + + + + + + + +

Lorem ipsum dolor sit amet.

+

Lorem ipsum dolor sit amet.

+

Lorem ipsum dolor sit amet.

+

Lorem ipsum dolor sit amet.

+ + + diff --git a/tests/_files/OutputTest/inline/absolute-child.pdf b/tests/_files/OutputTest/inline/absolute-child.pdf new file mode 100644 index 000000000..55b45803f Binary files /dev/null and b/tests/_files/OutputTest/inline/absolute-child.pdf differ diff --git a/tests/_files/OutputTest/inline/inline-decoration.html b/tests/_files/OutputTest/inline/inline-decoration.html new file mode 100644 index 000000000..8010cf1fc --- /dev/null +++ b/tests/_files/OutputTest/inline/inline-decoration.html @@ -0,0 +1,41 @@ + + + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor + incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + + diff --git a/tests/_files/OutputTest/inline/inline-decoration.pdf b/tests/_files/OutputTest/inline/inline-decoration.pdf new file mode 100644 index 000000000..1cd431986 Binary files /dev/null and b/tests/_files/OutputTest/inline/inline-decoration.pdf differ diff --git a/tests/_files/OutputTest/inline/text-indent-break.html b/tests/_files/OutputTest/inline/text-indent-break.html new file mode 100644 index 000000000..fd93b492d --- /dev/null +++ b/tests/_files/OutputTest/inline/text-indent-break.html @@ -0,0 +1,46 @@ + + + + + + + + + + +

Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor + incidunt ut labore et dolore magna aliqua.

+

Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor + incidunt ut labore et dolore magna aliqua.

+

Loremipsumdolorsitametconsecteturadipisicielitsedeiusmodtempor.

+

+ Loremipsumdolorsitametconsecteturadipisicielitsedeiusmodtempor. +

+ + + diff --git a/tests/_files/OutputTest/inline/text-indent-break.pdf b/tests/_files/OutputTest/inline/text-indent-break.pdf new file mode 100644 index 000000000..bb7fd0268 Binary files /dev/null and b/tests/_files/OutputTest/inline/text-indent-break.pdf differ diff --git a/tests/_files/OutputTest/inline/white-space-after-inline.html b/tests/_files/OutputTest/inline/white-space-after-inline.html new file mode 100644 index 000000000..8e048c716 --- /dev/null +++ b/tests/_files/OutputTest/inline/white-space-after-inline.html @@ -0,0 +1,46 @@ + + + + + + + + + + +

+ Loremipsumdolorsitametconsecteturadipisicielitsed +
Loremipsum +

+

+ Loremipsumdolorsitametconsecteturadipisicielitsed +
Loremipsum +

+

+ Loremipsumdolorsitametconsecteturadipisicielitsed +
Loremipsum +

+ + + diff --git a/tests/_files/OutputTest/inline/white-space-after-inline.pdf b/tests/_files/OutputTest/inline/white-space-after-inline.pdf new file mode 100644 index 000000000..aa0be8f47 Binary files /dev/null and b/tests/_files/OutputTest/inline/white-space-after-inline.pdf differ diff --git a/tests/_files/OutputTest/page-break/box-decoration.html b/tests/_files/OutputTest/page-break/box-decoration.html index 02dcd7bc3..6c20cfb6e 100644 --- a/tests/_files/OutputTest/page-break/box-decoration.html +++ b/tests/_files/OutputTest/page-break/box-decoration.html @@ -4,8 +4,8 @@ + content="The box decoration should be sliced, except for the outline. The + box should extend to the bottom on the first page, but it currently does not">