1895 lines
71 KiB
PHP
1895 lines
71 KiB
PHP
<?php
|
|
/**
|
|
* @package dompdf
|
|
* @link https://github.com/dompdf/dompdf
|
|
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
|
|
*/
|
|
namespace Dompdf\Css;
|
|
|
|
use DOMElement;
|
|
use DOMXPath;
|
|
use Dompdf\Css\Content\Url;
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Exception;
|
|
use Dompdf\FontMetrics;
|
|
use Dompdf\Frame\FrameTree;
|
|
use Dompdf\Helpers;
|
|
|
|
/**
|
|
* The master stylesheet class
|
|
*
|
|
* The Stylesheet class is responsible for parsing stylesheets and style
|
|
* tags/attributes. It also acts as a registry of the individual Style
|
|
* objects generated by the current set of loaded CSS files and style
|
|
* elements.
|
|
*
|
|
* @see Style
|
|
* @package dompdf
|
|
*/
|
|
class Stylesheet
|
|
{
|
|
/**
|
|
* The location of the default built-in CSS file.
|
|
*/
|
|
const DEFAULT_STYLESHEET = "/lib/res/html.css";
|
|
|
|
/**
|
|
* User agent stylesheet origin
|
|
*
|
|
* @var int
|
|
*/
|
|
const ORIG_UA = 1;
|
|
|
|
/**
|
|
* User normal stylesheet origin
|
|
*
|
|
* @var int
|
|
*/
|
|
const ORIG_USER = 2;
|
|
|
|
/**
|
|
* Author normal stylesheet origin
|
|
*
|
|
* @var int
|
|
*/
|
|
const ORIG_AUTHOR = 3;
|
|
|
|
/**
|
|
* RegEx pattern representing a CSS string
|
|
*
|
|
* @var string
|
|
*/
|
|
const PATTERN_CSS_STRING = '(?<CSS_STRING>(?<CSS_STRING_QUOTE>[\'"])(?<CSS_STRING_VALUE>.*?)(?<!\\\\)\g{CSS_STRING_QUOTE})';
|
|
|
|
/**
|
|
* RegEx pattern representing the CSS var() function
|
|
*
|
|
* @var string
|
|
*/
|
|
const PATTERN_CSS_VAR_FN = "var\((([^()]|(?R))*)\)";
|
|
|
|
/**
|
|
* RegEx pattern representing the CSS url() function
|
|
*
|
|
* @var string
|
|
*/
|
|
const PATTERN_CSS_URL_FN = '(?<CSS_URL_FN>url\(\s*(?<CSS_URL_FN_QUOTE>[\'"]?)(?<CSS_URL_FN_VALUE>.*?)(?(CSS_URL_FN_QUOTE)(?<!\\\\)\g{CSS_URL_FN_QUOTE})\s*\))';
|
|
|
|
/**
|
|
* RegEx pattern representing the CSS local() function
|
|
*
|
|
* @var string
|
|
*/
|
|
const PATTERN_CSS_LOCAL_FN = '(?<CSS_LOCAL_FN>local\(\s*(?<CSS_LOCAL_FN_QUOTE>[\'"]?)(?<CSS_LOCAL_FN_VALUE>.*?)(?(CSS_LOCAL_FN_QUOTE)(?<!\\\\)\g{CSS_LOCAL_FN_QUOTE})\s*\))';
|
|
|
|
/**
|
|
* RegEx pattern representing a CSS media query
|
|
*
|
|
* @var string
|
|
*/
|
|
const PATTERN_MEDIA_QUERY = '(?<CSS_MEDIA_QUERY>(?:(?:(?:(?<CSS_MEDIA_QUERY_OP>only|not)\s+)?(?<CSS_MEDIA_QUERY_TYPE>all|aural|bitmap|braille|dompdf|embossed|handheld|paged|print|projection|screen|speech|static|tty|tv|visual))|(?:\(\s*(?<CSS_MEDIA_QUERY_FEATURE>(?:(?:(?:min|max)-)?(?:width|height))|orientation|[^:]*?)\s*(?:\:\s*(?<CSS_MEDIA_QUERY_CONDITION>.*?)\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
|
|
* adding multiples of 0x10000000 to the actual specificity. Important
|
|
* declarations are handled in Style; though technically they should be handled
|
|
* here so that user important declarations can be made to take precedence over
|
|
* user important declarations, this doesn't matter in practice as Dompdf does
|
|
* not support user stylesheets, and user agent stylesheets can not include
|
|
* important declarations.
|
|
*/
|
|
private static $_stylesheet_origins = [
|
|
self::ORIG_UA => 0x00000000, // user agent declarations
|
|
self::ORIG_USER => 0x10000000, // user normal declarations
|
|
self::ORIG_AUTHOR => 0x30000000, // author normal declarations
|
|
];
|
|
|
|
/**
|
|
* Non-CSS presentational hints (i.e. HTML 4 attributes) are handled as if added
|
|
* to the beginning of an author stylesheet, i.e. anything in author stylesheets
|
|
* should override them.
|
|
*/
|
|
const SPEC_NON_CSS = 0x20000000;
|
|
|
|
/**
|
|
* Current dompdf instance
|
|
*
|
|
* @var Dompdf
|
|
*/
|
|
private $_dompdf;
|
|
|
|
/**
|
|
* Array of currently defined styles
|
|
*
|
|
* @var array<string, Style[]>
|
|
*/
|
|
private $_styles;
|
|
|
|
/**
|
|
* Array of embedded files (dataURIs) found in the parsed CSS
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private $_blobs;
|
|
|
|
/**
|
|
* Base protocol of the document being parsed
|
|
* Used to handle relative urls.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $_protocol = "";
|
|
|
|
/**
|
|
* Base hostname of the document being parsed
|
|
* Used to handle relative urls.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $_base_host = "";
|
|
|
|
/**
|
|
* Base path of the document being parsed
|
|
* Used to handle relative urls.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $_base_path = "";
|
|
|
|
/**
|
|
* The styles defined by @page rules
|
|
*
|
|
* @var array<Style>
|
|
*/
|
|
private $_page_styles;
|
|
|
|
/**
|
|
* List of loaded files, used to prevent recursion
|
|
*
|
|
* @var array
|
|
*/
|
|
private $_loaded_files;
|
|
|
|
/**
|
|
* Current stylesheet origin
|
|
*
|
|
* @var int
|
|
*/
|
|
private $_current_origin = self::ORIG_UA;
|
|
|
|
/**
|
|
* Accepted CSS media types
|
|
* List of types and parsing rules for future extensions:
|
|
* http://www.w3.org/TR/REC-html40/types.html
|
|
* screen, tty, tv, projection, handheld, print, braille, aural, all
|
|
* The following are non standard extensions for undocumented specific environments.
|
|
* static, visual, bitmap, paged, dompdf
|
|
* Note, even though the generated pdf file is intended for print output,
|
|
* the desired content might be different (e.g. screen or projection view of html file).
|
|
* Therefore allow specification of content by dompdf setting Options::defaultMediaType.
|
|
* If given, replace media "print" by Options::defaultMediaType.
|
|
* (Previous version $ACCEPTED_MEDIA_TYPES = $ACCEPTED_GENERIC_MEDIA_TYPES + $ACCEPTED_DEFAULT_MEDIA_TYPE)
|
|
*/
|
|
static $ACCEPTED_DEFAULT_MEDIA_TYPE = "print";
|
|
static $ACCEPTED_GENERIC_MEDIA_TYPES = ["all", "static", "visual", "bitmap", "paged", "dompdf"];
|
|
static $VALID_MEDIA_TYPES = ["all", "aural", "bitmap", "braille", "dompdf", "embossed", "handheld", "paged", "print", "projection", "screen", "speech", "static", "tty", "tv", "visual"];
|
|
|
|
/**
|
|
* @var FontMetrics
|
|
*/
|
|
private $fontMetrics;
|
|
|
|
/**
|
|
* The class constructor.
|
|
*
|
|
* The base protocol, host & path are initialized to those of
|
|
* the current script.
|
|
*/
|
|
function __construct(Dompdf $dompdf)
|
|
{
|
|
$this->_dompdf = $dompdf;
|
|
$this->setFontMetrics($dompdf->getFontMetrics());
|
|
$this->_styles = [];
|
|
$this->_loaded_files = [];
|
|
$script = __FILE__;
|
|
if (isset($_SERVER["SCRIPT_FILENAME"])) {
|
|
$script = $_SERVER["SCRIPT_FILENAME"];
|
|
}
|
|
list($this->_protocol, $this->_base_host, $this->_base_path) = Helpers::explode_url($script);
|
|
$this->_page_styles = ["base" => new Style($this)];
|
|
}
|
|
|
|
/**
|
|
* Set the base protocol
|
|
*
|
|
* @param string $protocol
|
|
*/
|
|
function set_protocol(string $protocol)
|
|
{
|
|
$this->_protocol = $protocol;
|
|
}
|
|
|
|
/**
|
|
* Set the base host
|
|
*
|
|
* @param string $host
|
|
*/
|
|
function set_host(string $host)
|
|
{
|
|
$this->_base_host = $host;
|
|
}
|
|
|
|
/**
|
|
* Set the base path
|
|
*
|
|
* @param string $path
|
|
*/
|
|
function set_base_path(string $path)
|
|
{
|
|
$this->_base_path = $path;
|
|
}
|
|
|
|
/**
|
|
* Return the Dompdf object
|
|
*
|
|
* @return Dompdf
|
|
*/
|
|
function get_dompdf()
|
|
{
|
|
return $this->_dompdf;
|
|
}
|
|
|
|
/**
|
|
* Return the base protocol for this stylesheet
|
|
*
|
|
* @return string
|
|
*/
|
|
function get_protocol()
|
|
{
|
|
return $this->_protocol;
|
|
}
|
|
|
|
/**
|
|
* Return the base host for this stylesheet
|
|
*
|
|
* @return string
|
|
*/
|
|
function get_host()
|
|
{
|
|
return $this->_base_host;
|
|
}
|
|
|
|
/**
|
|
* Return the base path for this stylesheet
|
|
*
|
|
* @return string
|
|
*/
|
|
function get_base_path()
|
|
{
|
|
return $this->_base_path;
|
|
}
|
|
|
|
/**
|
|
* Get all registered styles as an associative array, indexed by selector.
|
|
*
|
|
* @return array<string, Style[]>
|
|
*/
|
|
public function get_styles(): array
|
|
{
|
|
return $this->_styles;
|
|
}
|
|
|
|
/**
|
|
* Return the array of page styles
|
|
*
|
|
* @return Style[]
|
|
*/
|
|
function get_page_styles()
|
|
{
|
|
return $this->_page_styles;
|
|
}
|
|
|
|
/**
|
|
* Create a new Style object associated with this stylesheet
|
|
*
|
|
* @return Style
|
|
*/
|
|
function create_style(): Style
|
|
{
|
|
return new Style($this, $this->_current_origin);
|
|
}
|
|
|
|
/**
|
|
* Add a new Style object to the stylesheet
|
|
*
|
|
* The style's origin is changed to the current origin of the stylesheet.
|
|
*
|
|
* @param string $key the Style's selector
|
|
* @param Style $style the Style to be added
|
|
*/
|
|
function add_style(string $key, Style $style): void
|
|
{
|
|
if (!isset($this->_styles[$key])) {
|
|
$this->_styles[$key] = [];
|
|
}
|
|
|
|
$style->set_origin($this->_current_origin);
|
|
$this->_styles[$key][] = $style;
|
|
}
|
|
|
|
/**
|
|
* load and parse a CSS string
|
|
*
|
|
* @param string $css
|
|
* @param int $origin
|
|
*/
|
|
function load_css(&$css, $origin = self::ORIG_AUTHOR)
|
|
{
|
|
if ($origin) {
|
|
$this->_current_origin = $origin;
|
|
}
|
|
$this->_parse_css($css);
|
|
}
|
|
|
|
/**
|
|
* load and parse a CSS file
|
|
*
|
|
* @param string $file
|
|
* @param int $origin
|
|
*/
|
|
function load_css_file($file, $origin = self::ORIG_AUTHOR)
|
|
{
|
|
if ($origin) {
|
|
$this->_current_origin = $origin;
|
|
}
|
|
|
|
// Prevent circular references
|
|
if (isset($this->_loaded_files[$file])) {
|
|
return;
|
|
}
|
|
$this->_loaded_files[$file] = true;
|
|
|
|
$parsed_url = Helpers::explode_url($file);
|
|
$protocol = $parsed_url["protocol"];
|
|
|
|
if ($file !== $this->getDefaultStylesheet()) {
|
|
$options = $this->_dompdf->getOptions();
|
|
$allowed_protocols = $options->getAllowedProtocols();
|
|
if (!array_key_exists($protocol, $allowed_protocols)) {
|
|
Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The communication protocol is not supported.", __FILE__, __LINE__);
|
|
return;
|
|
}
|
|
foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
|
|
[$result, $message] = $rule($file);
|
|
if (!$result) {
|
|
Helpers::record_warnings(E_USER_WARNING, "Error loading $file: $message", __FILE__, __LINE__);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (strpos($file, "data:") === 0) {
|
|
$parsed = Helpers::parse_data_uri($file);
|
|
$css = $parsed["data"];
|
|
} else {
|
|
[$css, $http_response_header] = Helpers::getFileContent($file, $this->_dompdf->getHttpContext());
|
|
|
|
$good_mime_type = true;
|
|
if (isset($http_response_header) && !$this->_dompdf->getQuirksmode()) {
|
|
foreach ($http_response_header as $_header) {
|
|
if (preg_match("@Content-Type:\s*([\w/]+)@i", $_header, $matches) &&
|
|
($matches[1] !== "text/css")
|
|
) {
|
|
$good_mime_type = false;
|
|
}
|
|
}
|
|
}
|
|
if (!$good_mime_type || $css === null) {
|
|
Helpers::record_warnings(E_USER_WARNING, "Unable to load css file $file", __FILE__, __LINE__);
|
|
return;
|
|
}
|
|
|
|
[$this->_protocol, $this->_base_host, $this->_base_path] = $parsed_url;
|
|
}
|
|
|
|
$this->_parse_css($css);
|
|
}
|
|
|
|
/**
|
|
* @link https://www.w3.org/TR/CSS21/cascade.html#specificity
|
|
*
|
|
* @param string $selector
|
|
* @param int $origin
|
|
* - Stylesheet::ORIG_UA: user agent style sheet
|
|
* - Stylesheet::ORIG_USER: user style sheet
|
|
* - Stylesheet::ORIG_AUTHOR: author style sheet
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function specificity(string $selector, int $origin = self::ORIG_AUTHOR): int
|
|
{
|
|
$a = ($selector === "!attr") ? 1 : 0;
|
|
|
|
$b = min(mb_substr_count($selector, "#"), 255);
|
|
|
|
$c = min(mb_substr_count($selector, ".") +
|
|
mb_substr_count($selector, "[") +
|
|
mb_substr_count($selector, ":") -
|
|
2 * mb_substr_count($selector, "::"), 255);
|
|
|
|
$d = min(mb_substr_count($selector, " ") +
|
|
mb_substr_count($selector, ">") +
|
|
mb_substr_count($selector, "+") +
|
|
mb_substr_count($selector, "~") -
|
|
mb_substr_count($selector, "~=") +
|
|
mb_substr_count($selector, "::"), 255);
|
|
|
|
//If a normal element name is at the beginning of the string,
|
|
//a leading whitespace might have been removed on whitespace collapsing and removal
|
|
//therefore there might be one whitespace less as selected element names
|
|
//this can lead to a too small specificity
|
|
//see selectorToXpath
|
|
|
|
if (!in_array($selector[0], [" ", ">", ".", "#", "+", "~", ":", "["], true) && $selector !== "*") {
|
|
$d++;
|
|
}
|
|
|
|
if ($this->_dompdf->getOptions()->getDebugCss()) {
|
|
/*DEBUGCSS*/
|
|
print "<pre>\n";
|
|
/*DEBUGCSS*/
|
|
printf("specificity(): 0x%08x \"%s\"\n", self::$_stylesheet_origins[$origin] + (($a << 24) | ($b << 16) | ($c << 8) | ($d)), $selector);
|
|
/*DEBUGCSS*/
|
|
print "</pre>";
|
|
}
|
|
|
|
return self::$_stylesheet_origins[$origin] + (($a << 24) | ($b << 16) | ($c << 8) | ($d));
|
|
}
|
|
|
|
/**
|
|
* Converts a CSS selector to an XPath query.
|
|
*
|
|
* @param string $selector
|
|
* @param bool $firstPass
|
|
*
|
|
* @return array|null
|
|
*/
|
|
protected function selectorToXpath(string $selector, bool $firstPass = false): ?array
|
|
{
|
|
// Collapse white space and strip whitespace around delimiters
|
|
//$search = array("/\\s+/", "/\\s+([.>#+:])\\s+/");
|
|
//$replace = array(" ", "\\1");
|
|
//$selector = preg_replace($search, $replace, trim($selector));
|
|
|
|
// Initial query, always expanded to // below (non-absolute)
|
|
$query = "/";
|
|
|
|
// Will contain :before and :after
|
|
$pseudo_elements = [];
|
|
|
|
// Parse the selector
|
|
//$s = preg_split("/([ :>.#+])/", $selector, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
|
|
$delimiters = [" ", ">", ".", "#", "+", "~", ":", "[", "("];
|
|
|
|
// Add an implicit space at the beginning of the selector if there is no
|
|
// delimiter there already.
|
|
if (!in_array($selector[0], $delimiters, true)) {
|
|
$selector = " $selector";
|
|
}
|
|
|
|
$name = "*";
|
|
$len = mb_strlen($selector, "UTF-8");
|
|
$i = 0;
|
|
|
|
while ($i < $len) {
|
|
|
|
$s = $selector[$i];
|
|
$i++;
|
|
|
|
// Eat characters up to the next delimiter
|
|
$tok = "";
|
|
$escape = false;
|
|
$in_attr = false;
|
|
$in_func = false;
|
|
|
|
while ($i < $len) {
|
|
$c = $selector[$i];
|
|
$c_prev = $selector[$i - 1];
|
|
|
|
if ($c_prev === "\\" && !$escape) {
|
|
$escape = true;
|
|
} elseif ($escape === true) {
|
|
$escape = false;
|
|
}
|
|
|
|
if (!$escape && !$in_func && !$in_attr && in_array($c, $delimiters, true) && !($c === $c_prev && $c === ":")) {
|
|
break;
|
|
}
|
|
|
|
if ($c_prev === "[") {
|
|
$in_attr = true;
|
|
}
|
|
if ($c_prev === "(") {
|
|
$in_func = true;
|
|
}
|
|
|
|
$tok .= $selector[$i++];
|
|
|
|
if (!$escape && $in_attr && $c === "]") {
|
|
$in_attr = false;
|
|
break;
|
|
}
|
|
if (!$escape && $in_func && $c === ")") {
|
|
$in_func = false;
|
|
break;
|
|
}
|
|
}
|
|
$tok = $this->parse_string($tok);
|
|
|
|
|
|
switch ($s) {
|
|
|
|
case " ":
|
|
case ">":
|
|
// All elements matching the next token that are descendants
|
|
// or children of the current token
|
|
// https://www.w3.org/TR/selectors-3/#descendant-combinators
|
|
// https://www.w3.org/TR/selectors-3/#child-combinators
|
|
$expr = $s === " " ? "descendant" : "child";
|
|
|
|
// Tag names are case-insensitive
|
|
$name = $tok === "" ? "*" : strtolower($tok);
|
|
$query .= "/$expr::$name";
|
|
break;
|
|
|
|
case "+":
|
|
// Next-sibling combinator
|
|
// https://www.w3.org/TR/selectors-3/#sibling-combinators
|
|
|
|
// Tag names are case-insensitive
|
|
$name = $tok === "" ? "*" : strtolower($tok);
|
|
$query .= "/following-sibling::*[1]";
|
|
|
|
if ($name !== "*") {
|
|
$query .= "[name() = '$name']";
|
|
}
|
|
break;
|
|
|
|
case "~":
|
|
// Subsequent-sibling combinator
|
|
// https://www.w3.org/TR/selectors-3/#sibling-combinators
|
|
|
|
// Tag names are case-insensitive
|
|
$name = $tok === "" ? "*" : strtolower($tok);
|
|
$query .= "/following-sibling::$name";
|
|
break;
|
|
|
|
case "#":
|
|
// All elements matching the current token with id equal
|
|
// to the _next_ token
|
|
// https://www.w3.org/TR/selectors-3/#id-selectors
|
|
if ($query === "/") {
|
|
$query .= "/*";
|
|
}
|
|
|
|
$query .= "[@id=\"$tok\"]";
|
|
break;
|
|
|
|
case ".":
|
|
// All elements matching the current token with a class
|
|
// equal to the _next_ token
|
|
// https://www.w3.org/TR/selectors-3/#class-html
|
|
if ($query === "/") {
|
|
$query .= "/*";
|
|
}
|
|
|
|
// Match multiple classes: $tok contains the current selected
|
|
// class. Search for class attributes with class="$tok",
|
|
// class=".* $tok .*" and class=".* $tok"
|
|
|
|
// This doesn't work because libxml only supports XPath 1.0...
|
|
//$query .= "[matches(@$attr,\"^{$tok}\$|^{$tok}[ ]+|[ ]+{$tok}\$|[ ]+{$tok}[ ]+\")]";
|
|
|
|
$query .= "[contains(concat(' ', normalize-space(@class), ' '), concat(' ', '$tok', ' '))]";
|
|
break;
|
|
|
|
case ":":
|
|
if ($query === "/") {
|
|
$query .= "/*";
|
|
}
|
|
|
|
$last = false;
|
|
|
|
// Pseudo-classes
|
|
switch ($tok) {
|
|
|
|
case "root":
|
|
$query .= "[not(parent::*)]";
|
|
break;
|
|
|
|
case "first-child":
|
|
$query .= "[not(preceding-sibling::*)]";
|
|
break;
|
|
|
|
case "last-child":
|
|
$query .= "[not(following-sibling::*)]";
|
|
break;
|
|
|
|
case "only-child":
|
|
$query .= "[not(preceding-sibling::*) and not(following-sibling::*)]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#nth-child-pseudo
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case "nth-last-child":
|
|
$last = true;
|
|
case "nth-child":
|
|
$p = $i + 1;
|
|
$nth = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
|
|
$position = $last
|
|
? "(count(following-sibling::*) + 1)"
|
|
: "(count(preceding-sibling::*) + 1)";
|
|
|
|
$condition = $this->selectorAnPlusB($nth, $position);
|
|
$query .= "[$condition]";
|
|
break;
|
|
|
|
// TODO: `*:first-of-type`, `*:nth-of-type` etc.
|
|
// (without fixed element name) are treated equivalent
|
|
// to their `:*-child` counterparts here. They might
|
|
// not be properly expressible in XPath 1.0
|
|
case "first-of-type":
|
|
$query .= "[not(preceding-sibling::$name)]";
|
|
break;
|
|
|
|
case "last-of-type":
|
|
$query .= "[not(following-sibling::$name)]";
|
|
break;
|
|
|
|
case "only-of-type":
|
|
$query .= "[not(preceding-sibling::$name) and not(following-sibling::$name)]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#nth-of-type-pseudo
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case "nth-last-of-type":
|
|
$last = true;
|
|
case "nth-of-type":
|
|
$p = $i + 1;
|
|
$nth = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
|
|
$position = $last
|
|
? "(count(following-sibling::$name) + 1)"
|
|
: "(count(preceding-sibling::$name) + 1)";
|
|
|
|
$condition = $this->selectorAnPlusB($nth, $position);
|
|
$query .= "[$condition]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-4/#empty-pseudo
|
|
case "empty":
|
|
$query .= "[not(*) and not(normalize-space())]";
|
|
break;
|
|
|
|
// TODO: bit of a hack attempt at matches support, currently only matches against elements
|
|
case "matches":
|
|
$p = $i + 1;
|
|
$matchList = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
|
|
|
|
// Tag names are case-insensitive
|
|
$elements = array_map("trim", explode(",", strtolower($matchList)));
|
|
foreach ($elements as &$element) {
|
|
$element = "name() = '$element'";
|
|
}
|
|
|
|
$query .= "[" . implode(" or ", $elements) . "]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#UIstates
|
|
case "disabled":
|
|
case "checked":
|
|
$query .= "[@$tok]";
|
|
break;
|
|
|
|
case "enabled":
|
|
$query .= "[not(@disabled)]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#dynamic-pseudos
|
|
// https://www.w3.org/TR/selectors-4/#the-any-link-pseudo
|
|
case "link":
|
|
case "any-link":
|
|
$query .= "[@href]";
|
|
break;
|
|
|
|
// N/A
|
|
case "visited":
|
|
case "hover":
|
|
case "active":
|
|
case "focus":
|
|
case "focus-visible":
|
|
case "focus-within":
|
|
$query .= "[false()]";
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#first-line
|
|
// https://www.w3.org/TR/selectors-3/#first-letter
|
|
case "first-line":
|
|
case ":first-line":
|
|
case "first-letter":
|
|
case ":first-letter":
|
|
// TODO
|
|
$el = ltrim($tok, ":");
|
|
$pseudo_elements[$el] = true;
|
|
break;
|
|
|
|
// https://www.w3.org/TR/selectors-3/#gen-content
|
|
case "before":
|
|
case ":before":
|
|
case "after":
|
|
case ":after":
|
|
$pos = ltrim($tok, ":");
|
|
$pseudo_elements[$pos] = true;
|
|
if (!$firstPass) {
|
|
$query .= "/*[@$pos]";
|
|
}
|
|
break;
|
|
|
|
// Invalid or unsupported pseudo-class or pseudo-element
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
break;
|
|
|
|
case "[":
|
|
// Attribute selectors. All with an attribute matching the
|
|
// following token(s)
|
|
// https://www.w3.org/TR/selectors-3/#attribute-selectors
|
|
if ($query === "/") {
|
|
$query .= "/*";
|
|
}
|
|
|
|
$attr_delimiters = ["=", "]", "~", "|", "$", "^", "*"];
|
|
$tok_len = mb_strlen($tok, "UTF-8");
|
|
$j = 0;
|
|
|
|
$attr = "";
|
|
$op = "";
|
|
$value = "";
|
|
|
|
while ($j < $tok_len) {
|
|
if (in_array($tok[$j], $attr_delimiters, true)) {
|
|
break;
|
|
}
|
|
$attr .= $tok[$j++];
|
|
}
|
|
|
|
if ($attr === "") {
|
|
// Selector invalid: Missing attribute name
|
|
return null;
|
|
}
|
|
|
|
if (!isset($tok[$j])) {
|
|
// Selector invalid: Missing ] or operator
|
|
return null;
|
|
}
|
|
|
|
switch ($tok[$j]) {
|
|
|
|
case "~":
|
|
case "|":
|
|
case "^":
|
|
case "$":
|
|
case "*":
|
|
$op .= $tok[$j++];
|
|
|
|
if (!isset($tok[$j]) || $tok[$j] !== "=") {
|
|
// Selector invalid: Incomplete attribute operator
|
|
return null;
|
|
}
|
|
|
|
$op .= $tok[$j];
|
|
break;
|
|
|
|
case "=":
|
|
$op = "=";
|
|
break;
|
|
|
|
}
|
|
|
|
// Read the attribute value, if required
|
|
if ($op !== "") {
|
|
$j++;
|
|
while ($j < $tok_len) {
|
|
if ($tok[$j] === "]") {
|
|
break;
|
|
}
|
|
$value .= $tok[$j++];
|
|
}
|
|
}
|
|
|
|
if (!isset($tok[$j])) {
|
|
// Selector invalid: Missing ]
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value, "\"'");
|
|
|
|
switch ($op) {
|
|
|
|
case "":
|
|
$query .= "[@$attr]";
|
|
break;
|
|
|
|
case "=":
|
|
$query .= "[@$attr=\"$value\"]";
|
|
break;
|
|
|
|
case "~=":
|
|
// FIXME: this will break if $value contains quoted strings
|
|
// (e.g. [type~="a b c" "d e f"])
|
|
$query .= $value !== "" && !preg_match("/\s+/", $value)
|
|
? "[contains(concat(' ', normalize-space(@$attr), ' '), concat(' ', \"$value\", ' '))]"
|
|
: "[false()]";
|
|
break;
|
|
|
|
case "|=":
|
|
$values = explode("-", $value);
|
|
$query .= "[";
|
|
|
|
foreach ($values as $val) {
|
|
$query .= "starts-with(@$attr, \"$val\") or ";
|
|
}
|
|
|
|
$query = rtrim($query, " or ") . "]";
|
|
break;
|
|
|
|
case "^=":
|
|
$query .= $value !== ""
|
|
? "[starts-with(@$attr,\"$value\")]"
|
|
: "[false()]";
|
|
break;
|
|
|
|
case "$=":
|
|
$query .= $value !== ""
|
|
? "[substring(@$attr, string-length(@$attr)-" . (strlen($value) - 1) . ")=\"$value\"]"
|
|
: "[false()]";
|
|
break;
|
|
|
|
case "*=":
|
|
$query .= $value !== ""
|
|
? "[contains(@$attr,\"$value\")]"
|
|
: "[false()]";
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return ["query" => $query, "pseudo_elements" => $pseudo_elements];
|
|
}
|
|
|
|
/**
|
|
* Parse an `nth-child` expression of the form `an+b`, `odd`, or `even`.
|
|
*
|
|
* @param string $expr
|
|
* @param string $position
|
|
*
|
|
* @return string
|
|
*
|
|
* @link https://www.w3.org/TR/selectors-3/#nth-child-pseudo
|
|
*/
|
|
protected function selectorAnPlusB(string $expr, string $position): string
|
|
{
|
|
// odd
|
|
if ($expr === "odd") {
|
|
return "($position mod 2) = 1";
|
|
} // even
|
|
elseif ($expr === "even") {
|
|
return "($position mod 2) = 0";
|
|
} // b
|
|
elseif (preg_match("/^\d+$/", $expr)) {
|
|
return "$position = $expr";
|
|
}
|
|
|
|
// an+b
|
|
// https://github.com/tenderlove/nokogiri/blob/master/lib/nokogiri/css/xpath_visitor.rb
|
|
$expr = preg_replace("/\s/", "", $expr);
|
|
if (!preg_match("/^(?P<a>-?[0-9]*)?n(?P<b>[-+]?[0-9]+)?$/", $expr, $matches)) {
|
|
return "false()";
|
|
}
|
|
|
|
$a = (isset($matches["a"]) && $matches["a"] !== "") ? ($matches["a"] !== "-" ? intval($matches["a"]) : -1) : 1;
|
|
$b = (isset($matches["b"]) && $matches["b"] !== "") ? intval($matches["b"]) : 0;
|
|
|
|
if ($b === 0) {
|
|
return "($position mod $a) = 0";
|
|
} else {
|
|
$compare = ($a < 0) ? "<=" : ">=";
|
|
$b2 = -$b;
|
|
if ($b2 >= 0) {
|
|
$b2 = "+$b2";
|
|
}
|
|
return "($position $compare $b) and ((($position $b2) mod " . abs($a) . ") = 0)";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* applies all current styles to a particular document tree
|
|
*
|
|
* apply_styles() applies all currently loaded styles to the provided
|
|
* {@link FrameTree}. Aside from parsing CSS, this is the main purpose
|
|
* of this class.
|
|
*
|
|
* @param FrameTree $tree
|
|
*/
|
|
function apply_styles(FrameTree $tree)
|
|
{
|
|
// Use XPath to select nodes. This would be easier if we could attach
|
|
// Frame objects directly to DOMNodes using the setUserData() method, but
|
|
// we can't do that just yet. Instead, we set a _node attribute_ in
|
|
// Frame->set_id() and use that as a handle on the Frame object via
|
|
// FrameTree::$_registry.
|
|
|
|
// We create a scratch array of styles indexed by frame id. Once all
|
|
// styles have been assigned, we order the cached styles by specificity
|
|
// and create a final style object to assign to the frame.
|
|
|
|
// FIXME: this is not particularly robust...
|
|
|
|
$styles = [];
|
|
$xp = new DOMXPath($tree->get_dom());
|
|
$DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
|
|
|
|
// Add generated content
|
|
foreach ($this->_styles as $selector => $selector_styles) {
|
|
if (strpos($selector, ":before") === false && strpos($selector, ":after") === false) {
|
|
continue;
|
|
}
|
|
|
|
$query = $this->selectorToXpath($selector, true);
|
|
if ($query === null) {
|
|
Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
|
|
continue;
|
|
}
|
|
|
|
// Retrieve the nodes, limit to body for generated content
|
|
// TODO: If we use a context node can we remove the leading dot?
|
|
$nodes = @$xp->query('.' . $query["query"]);
|
|
if ($nodes === false) {
|
|
Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
|
|
continue;
|
|
}
|
|
|
|
foreach ($selector_styles as $style) {
|
|
foreach ($nodes as $node) {
|
|
// Only DOMElements get styles
|
|
if (!($node instanceof DOMElement)) {
|
|
continue;
|
|
}
|
|
|
|
foreach (array_keys($query["pseudo_elements"], true, true) as $pos) {
|
|
// Do not add a new pseudo element if another one already matched
|
|
if ($node->hasAttribute("dompdf_{$pos}_frame_id")) {
|
|
continue;
|
|
}
|
|
|
|
$content = $style->content;
|
|
|
|
// Do not create non-displayed before/after pseudo
|
|
// elements. Since styles have not been inherited yet,
|
|
// a specified value of `inherit` will always be treated
|
|
// as `normal` here. This is fine according to the
|
|
// CSS 2.1 spec, as any value computes to `normal` on
|
|
// regular elements
|
|
// https://www.w3.org/TR/CSS21/generate.html#content
|
|
// https://www.w3.org/TR/CSS21/generate.html#undisplayed-counters
|
|
if ($content === "normal" || $content === "none") {
|
|
$specified = $style->get_specified("content");
|
|
if (!\preg_match("/". self::PATTERN_CSS_VAR_FN . "/", $specified)) {
|
|
continue;
|
|
} else {
|
|
$content = [];
|
|
}
|
|
}
|
|
|
|
// https://www.w3.org/TR/css-content-3/#content-property
|
|
$single = count($content) === 1 ? $content[0] : null;
|
|
|
|
if ($single instanceof Url) {
|
|
$src = $this->resolve_url("url($single->url)", true);
|
|
$new_node = $node->ownerDocument->createElement("img_generated");
|
|
$new_node->setAttribute("src", $src);
|
|
} else {
|
|
$new_node = $node->ownerDocument->createElement("dompdf_generated");
|
|
}
|
|
|
|
$new_node->setAttribute($pos, $pos);
|
|
$new_frame_id = $tree->insert_node($node, $new_node, $pos);
|
|
$node->setAttribute("dompdf_{$pos}_frame_id", $new_frame_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply all styles in stylesheet
|
|
foreach ($this->_styles as $selector => $selector_styles) {
|
|
$query = $this->selectorToXpath($selector);
|
|
if ($query === null) {
|
|
Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
|
|
continue;
|
|
}
|
|
|
|
// Retrieve the nodes
|
|
$nodes = @$xp->query($query["query"]);
|
|
if ($nodes === false) {
|
|
Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
|
|
continue;
|
|
}
|
|
|
|
foreach ($selector_styles as $style) {
|
|
$spec = $this->specificity($selector, $style->get_origin());
|
|
|
|
foreach ($nodes as $node) {
|
|
// Only DOMElements get styles
|
|
if (!($node instanceof DOMElement)) {
|
|
continue;
|
|
}
|
|
|
|
$id = $node->getAttribute("frame_id");
|
|
|
|
// Assign the current style to the scratch array
|
|
$styles[$id][$spec][] = $style;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
|
|
if ($this->_page_styles["base"] && is_array($this->_page_styles["base"]->size)) {
|
|
$paper_width = $this->_page_styles['base']->size[0];
|
|
$paper_height = $this->_page_styles['base']->size[1];
|
|
$paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
|
|
}
|
|
|
|
// Now create the styles and assign them to the appropriate frames. (We
|
|
// iterate over the tree using an implicit FrameTree iterator.)
|
|
$root_flg = false;
|
|
foreach ($tree as $frame) {
|
|
// Helpers::pre_r($frame->get_node()->nodeName . ":");
|
|
if (!$root_flg && $this->_page_styles["base"]) {
|
|
$style = $this->_page_styles["base"];
|
|
} else {
|
|
$style = $this->create_style();
|
|
}
|
|
|
|
// Find nearest DOMElement parent
|
|
$p = $frame;
|
|
while ($p = $p->get_parent()) {
|
|
if ($p->get_node()->nodeType === XML_ELEMENT_NODE) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Styles can only be applied directly to DOMElements; anonymous
|
|
// frames inherit from their parent
|
|
if ($frame->get_node()->nodeType !== XML_ELEMENT_NODE) {
|
|
$style->inherit($p ? $p->get_style() : null);
|
|
$frame->set_style($style);
|
|
continue;
|
|
}
|
|
|
|
$id = $frame->get_id();
|
|
|
|
// Handle HTML 4.0 attributes
|
|
AttributeTranslator::translate_attributes($frame);
|
|
if (($str = $frame->get_node()->getAttribute(AttributeTranslator::$_style_attr)) !== "") {
|
|
$styles[$id][self::SPEC_NON_CSS][] = $this->_parse_properties($str);
|
|
}
|
|
|
|
// Locate any additional style attributes
|
|
if (($str = $frame->get_node()->getAttribute("style")) !== "") {
|
|
// Destroy CSS comments
|
|
$str = preg_replace("'/\*.*?\*/'si", "", $str);
|
|
|
|
$spec = $this->specificity("!attr", self::ORIG_AUTHOR);
|
|
$styles[$id][$spec][] = $this->_parse_properties($str);
|
|
}
|
|
|
|
// Grab the applicable styles
|
|
if (isset($styles[$id])) {
|
|
|
|
/** @var array[][] $applied_styles */
|
|
$applied_styles = $styles[$id];
|
|
|
|
// Sort by specificity
|
|
ksort($applied_styles);
|
|
|
|
if ($DEBUGCSS) {
|
|
$debug_nodename = $frame->get_node()->nodeName;
|
|
print "<pre>\n$debug_nodename [\n";
|
|
foreach ($applied_styles as $spec => $arr) {
|
|
printf(" specificity 0x%08x\n", $spec);
|
|
/** @var Style $s */
|
|
foreach ($arr as $s) {
|
|
print " [\n";
|
|
$s->debug_print();
|
|
print " ]\n";
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle inheritance
|
|
if ($p && $DEBUGCSS) {
|
|
print " inherit [\n";
|
|
$p->get_style()->debug_print();
|
|
print " ]\n";
|
|
}
|
|
|
|
$style->inherit($p ? $p->get_style() : null);
|
|
|
|
if ($DEBUGCSS) {
|
|
print " DomElementStyle [\n";
|
|
$style->debug_print();
|
|
print " ]\n";
|
|
print "]\n</pre>";
|
|
}
|
|
|
|
$style->clear_important();
|
|
$frame->set_style($style);
|
|
|
|
if (!$root_flg && $this->_page_styles["base"]) {
|
|
$root_flg = true;
|
|
|
|
// set the page width, height, and orientation based on the parsed page style
|
|
if ($style->size !== "auto") {
|
|
list($paper_width, $paper_height) = $style->size;
|
|
}
|
|
$paper_width = $paper_width - (float)$style->length_in_pt($style->margin_left) - (float)$style->length_in_pt($style->margin_right);
|
|
$paper_height = $paper_height - (float)$style->length_in_pt($style->margin_top) - (float)$style->length_in_pt($style->margin_bottom);
|
|
$paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
|
|
}
|
|
}
|
|
|
|
// We're done! Clean out the registry of all styles since we
|
|
// won't be needing this later.
|
|
foreach (array_keys($this->_styles) as $key) {
|
|
$this->_styles[$key] = null;
|
|
unset($this->_styles[$key]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* parse a CSS string using a regex parser
|
|
* Called by {@link Stylesheet::parse_css()}
|
|
*
|
|
* @param string $str
|
|
*/
|
|
private function _parse_css($str, $media_queries = [])
|
|
{
|
|
$str = trim($str);
|
|
|
|
// Destroy comments and remove HTML comments
|
|
$css = preg_replace([
|
|
"'/\*.*?\*/'si",
|
|
"/^<!--/",
|
|
"/-->$/"
|
|
], "", $str);
|
|
|
|
// 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:
|
|
// ... does not handle '{' within strings, e.g. [attr="string {}"]
|
|
$re = <<<EOL
|
|
/
|
|
# Skip leading whitespace
|
|
\s*
|
|
|
|
# Match at-rules
|
|
(?<CSS_ATRULE>@(?<CSS_ATRULE_IDENTIFIER>
|
|
(?<CSS_ATFONT>font-face)
|
|
|(?<CSS_ATIMPORT>import)
|
|
|(?<CSS_ATMEDIA>media)
|
|
|(?<CSS_ATPAGE>page)
|
|
|(?<CSS_AT>[\w-]*)
|
|
))?
|
|
|
|
# Branch to process segment following at-rule match
|
|
(?(CSS_ATRULE)(?:
|
|
(?(CSS_ATFONT)\s*{(?<CSS_ATFONT_BODY>.*?)})
|
|
(?(CSS_ATIMPORT)\s*(?<CSS_ATIMPORT_RULE>
|
|
(?<CSS_ATIMPORT_URL>
|
|
{$pattern_atimport_string}
|
|
|{$pattern_atimport_url}
|
|
)
|
|
(?<CSS_ATIMPORT_MEDIA_QUERY>.*?)
|
|
);)
|
|
(?(CSS_ATMEDIA)\s*(?<CSS_ATMEDIA_RULE>[^{]*){(?<CSS_ATMEDIA_BODY> (?:[^{}]*|(?R) \s*)+? )})
|
|
(?(CSS_ATPAGE)\s*(?<CSS_ATPAGE_RULE>[^{]*){(?<CSS_ATPAGE_BODY>.*?)})
|
|
(?(CSS_AT)\s*([^{;]*)(;|{(?<CSS_AT_BODY> (?:[^{}]*|(?R) \s*)+? )}))
|
|
)
|
|
|
|
# Branch to match regular rules (not preceded by '@')
|
|
|(?<CSS_RULESET>[^{]*{[^}]*}))
|
|
/isx
|
|
EOL;
|
|
|
|
// replace data URIs with blob URIs
|
|
$offset = 0;
|
|
while ($offset < strlen($css) && ($start = strpos($css, "data:", $offset)) !== false) {
|
|
$len = 0;
|
|
$prev = $css[$start - 1];
|
|
$pattern = "/(?<!\\\\)['\")]/";
|
|
switch ($prev) {
|
|
case "'":
|
|
$pattern = "/(?<!\\\\)'/";
|
|
case "\"":
|
|
$pattern = "/(?<!\\\\)\"/";
|
|
break;
|
|
case "(":
|
|
$pattern = "/(?<!\\\\)\\)/";
|
|
break;
|
|
default:
|
|
$offset = $start + 5;
|
|
continue (2);
|
|
}
|
|
if (preg_match($pattern, $css, $matches, PREG_OFFSET_CAPTURE, $start)) {
|
|
$len = $matches[0][1] - $start;
|
|
}
|
|
$data_uri = substr($css, $start, $len);
|
|
$data_uri_hash = md5($data_uri);
|
|
$this->_blobs[$data_uri_hash] = $data_uri;
|
|
$css = substr($css, 0, $start) . "blob://" . $data_uri_hash . ($len > 0 ? substr($css, $start + $len) : "");
|
|
$offset = $start + 7 + strlen($data_uri_hash);
|
|
}
|
|
|
|
$matches = [];
|
|
if (preg_match_all($re, $css, $matches, PREG_SET_ORDER) === false || count($matches) === 0) {
|
|
global $_dompdf_warnings;
|
|
$_dompdf_warnings[] = "Unable to parse CSS that starts with: " . substr($str, 0, 100);
|
|
return;
|
|
}
|
|
|
|
$media_query_regex = "/{$pattern_media_query}/isx";
|
|
$accepted_media = self::$ACCEPTED_GENERIC_MEDIA_TYPES;
|
|
$accepted_media[] = $this->_dompdf->getOptions()->getDefaultMediaType();
|
|
foreach ($matches as $match) {
|
|
if ($match["CSS_ATRULE_IDENTIFIER"] !== "") {
|
|
$atrule_identifier = strtolower($match["CSS_ATRULE_IDENTIFIER"]);
|
|
// Handle @rules
|
|
switch ($atrule_identifier) {
|
|
|
|
case "import":
|
|
$this->_parse_import($match["CSS_ATIMPORT_URL"], $match["CSS_ATIMPORT_MEDIA_QUERY"]);
|
|
break;
|
|
|
|
case "media":
|
|
$parsed_media_queries = [];
|
|
$media_query_rules = preg_split("/\s*(,|\Wor\W)\s*/", mb_strtolower(trim($match["CSS_ATMEDIA_RULE"])));
|
|
foreach ($media_query_rules as $media_query_rule) {
|
|
$media_query_matches = [];
|
|
if (preg_match_all($media_query_regex, $media_query_rule, $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) {
|
|
$parsed_media_queries[] = $mq_grouping;
|
|
}
|
|
}
|
|
$sub_media_queries = [];
|
|
if (count($media_queries) > 0) {
|
|
foreach ($media_queries as $media_query) {
|
|
foreach ($parsed_media_queries as $parsed_media_query) {
|
|
$sub_media_queries[] = array_merge($media_query, $parsed_media_query);
|
|
}
|
|
}
|
|
} else {
|
|
$sub_media_queries = $parsed_media_queries;
|
|
}
|
|
$this->_parse_css($match["CSS_ATMEDIA_BODY"], $sub_media_queries);
|
|
break;
|
|
|
|
case "page":
|
|
//This handles @page to be applied to page oriented media
|
|
//Note: This has a reduced syntax:
|
|
//@page { margin:1cm; color:blue; }
|
|
//Not a sequence of styles like a full.css, but only the properties
|
|
//of a single style, which is applied to the very first "root" frame before
|
|
//processing other styles of the frame.
|
|
//Working properties:
|
|
// margin (for margin around edge of paper)
|
|
// font-family (default font of pages)
|
|
// color (default text color of pages)
|
|
//Non working properties:
|
|
// border
|
|
// padding
|
|
// background-color
|
|
//Todo:Reason is unknown
|
|
//Other properties (like further font or border attributes) not tested.
|
|
//If a border or background color around each paper sheet is desired,
|
|
//assign it to the <body> tag, possibly only for the css of the correct media type.
|
|
|
|
// If the page has a name, skip the style.
|
|
$page_selector = trim($match["CSS_ATPAGE_RULE"]);
|
|
|
|
$key = null;
|
|
switch ($page_selector) {
|
|
case "":
|
|
$key = "base";
|
|
break;
|
|
|
|
case ":left":
|
|
case ":right":
|
|
case ":odd":
|
|
case ":even":
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case ":first":
|
|
$key = $page_selector;
|
|
break;
|
|
|
|
default:
|
|
break 2;
|
|
}
|
|
|
|
// Store the style for later...
|
|
if (empty($this->_page_styles[$key])) {
|
|
$this->_page_styles[$key] = $this->_parse_properties($match["CSS_ATPAGE_BODY"]);
|
|
} else {
|
|
$this->_page_styles[$key]->merge($this->_parse_properties($match["CSS_ATPAGE_BODY"]));
|
|
}
|
|
break;
|
|
|
|
case "font-face":
|
|
$this->_parse_font_face($match["CSS_ATFONT_BODY"]);
|
|
break;
|
|
|
|
default:
|
|
// ignore everything else
|
|
break;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($match["CSS_RULESET"] !== "") {
|
|
$this->_parse_sections($match["CSS_RULESET"], $media_queries);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the given `url()` declaration to an absolute URL.
|
|
*
|
|
* @param string|null $val The declaration to resolve in the context of the stylesheet.
|
|
* @param bool|null $resolve_blobs Indicates whether or not to resolve blob URLs to their final value.
|
|
* @return string The resolved URL, or `none`, if the value is `none`,
|
|
* invalid, or points to a non-existent local file.
|
|
*/
|
|
public function resolve_url($val, $resolve_blobs = false): string
|
|
{
|
|
$DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
|
|
|
|
static $pattern = "/" . self::PATTERN_CSS_URL_FN . "/isx";
|
|
if ($val === null || $val === "" || strcasecmp($val, "none") === 0) {
|
|
$path = "none";
|
|
} elseif (preg_match($pattern, $val, $matches)) {
|
|
// Resolve the url now in the context of the current stylesheet
|
|
$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;
|
|
}
|
|
if ($resolve_blobs === true && strpos($url, "blob://") !== false) {
|
|
$url = $this->_blobs[substr($url, 7)];
|
|
}
|
|
$path = Helpers::build_url(
|
|
$this->_protocol,
|
|
$this->_base_host,
|
|
$this->_base_path,
|
|
$url,
|
|
$this->_dompdf->getOptions()->getChroot()
|
|
);
|
|
if ($path === null) {
|
|
$path = "none";
|
|
}
|
|
} else {
|
|
$path = "none";
|
|
}
|
|
if ($DEBUGCSS) {
|
|
$parsed_url = Helpers::explode_url($path);
|
|
print "<pre>[_image\n";
|
|
print_r($parsed_url);
|
|
print $this->_protocol . "\n" . $this->_base_path . "\n" . $path . "\n";
|
|
print "_image]</pre>";
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* parse @import at-rule
|
|
*
|
|
* @param string $url the url of the imported CSS file
|
|
*/
|
|
private function _parse_import($url, $import_media_query)
|
|
{
|
|
// 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, true)) === "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 ?? ""), "UTF-8"));
|
|
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");
|
|
}
|
|
|
|
$acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES;
|
|
$acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType();
|
|
|
|
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;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
//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;
|
|
}
|
|
|
|
/**
|
|
* parse @font-face{} sections
|
|
* http://www.w3.org/TR/css3-fonts/#the-font-face-rule
|
|
*
|
|
* @param string $str CSS @font-face rules
|
|
*/
|
|
private function _parse_font_face($str)
|
|
{
|
|
$descriptors = $this->_parse_properties($str);
|
|
|
|
preg_match_all("/" . self::PATTERN_CSS_LOCAL_FN . "|" . self::PATTERN_CSS_URL_FN . "\s*(?<FORMAT>format\s*\((?<FORMAT_VALUE>collection|embedded-opentype|opentype|svg|truetype|woff|woff2|" . self::PATTERN_CSS_STRING . ")\))?/i", $descriptors->src, $sources, PREG_SET_ORDER);
|
|
|
|
$valid_sources = [];
|
|
foreach ($sources as $source) {
|
|
$urlfn = $source["CSS_URL_FN"] ?? "";
|
|
$format = strtolower($source["CSS_STRING_VALUE"] ?? $source["FORMAT_VALUE"] ?? "truetype");
|
|
|
|
if ($urlfn !== "" && $format === "truetype") {
|
|
$url = $this->resolve_url($urlfn, true);
|
|
if ($url === "none") {
|
|
continue;
|
|
}
|
|
$source_info = [
|
|
"uri" => $urlfn,
|
|
"format" => $format,
|
|
"path" => $url,
|
|
];
|
|
$valid_sources[] = $source_info;
|
|
}
|
|
}
|
|
|
|
if (empty($valid_sources)) {
|
|
return;
|
|
}
|
|
|
|
$style = [
|
|
"family" => $descriptors->get_font_family_raw(),
|
|
"weight" => $descriptors->font_weight,
|
|
"style" => $descriptors->font_style,
|
|
];
|
|
|
|
foreach ($valid_sources as $valid_source) {
|
|
if ($this->fontMetrics->registerFont($style, $valid_source["path"], $this->_dompdf->getHttpContext())) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* parse regular CSS blocks
|
|
*
|
|
* _parse_properties() creates a new Style object based on the provided
|
|
* CSS rules.
|
|
*
|
|
* @param string $str CSS rules
|
|
* @return Style
|
|
*/
|
|
private function _parse_properties($str)
|
|
{
|
|
$DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
|
|
|
|
if ($DEBUGCSS) {
|
|
print '[_parse_properties';
|
|
}
|
|
|
|
$delims = ['"' => '"', "'" => "'", '(' => ')'];
|
|
$delim = null;
|
|
$len = strlen($str);
|
|
$properties = [];
|
|
$char = null;
|
|
$prev = null;
|
|
for ($pos = 0; $pos < $len; $pos++) {
|
|
$prev = $char;
|
|
$char = $str[$pos];
|
|
if ($delim !== null) {
|
|
if ($char === $delims[$delim] && $prev !== '\\') {
|
|
$delim = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (isset($delims[$char]) && $prev !== '\\') {
|
|
$delim = $char;
|
|
continue;
|
|
}
|
|
if ($char === ';' && $prev !== '\\') {
|
|
$properties[] = substr($str, 0, $pos);
|
|
$str = substr($str, $pos+1);
|
|
$pos = 0;
|
|
$len = strlen($str);
|
|
}
|
|
}
|
|
$properties[] = $str;
|
|
$style = new Style($this, Stylesheet::ORIG_AUTHOR);
|
|
foreach ($properties as $prop) {
|
|
$prop = trim($prop);
|
|
if ($prop === "") {
|
|
continue;
|
|
}
|
|
|
|
$important = false;
|
|
if (substr($prop, -9) === 'important') {
|
|
$prop_tmp = rtrim(substr($prop, 0, -9));
|
|
|
|
if (substr($prop_tmp, -1) === '!') {
|
|
$prop = rtrim(substr($prop_tmp, 0, -1));
|
|
$important = true;
|
|
}
|
|
}
|
|
|
|
$i = strpos($prop, ":");
|
|
if ($i === false) {
|
|
if ($DEBUGCSS) {
|
|
print "(novalue $prop)";
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$prop_name = rtrim(substr($prop, 0, $i));
|
|
$value = ltrim(substr($prop, $i + 1));
|
|
|
|
// Regular (non-custom) properties are case-insensitive
|
|
if (strncmp($prop_name, "--", 2) !== 0) {
|
|
$prop_name = strtolower($prop_name);
|
|
}
|
|
|
|
if ($DEBUGCSS) {
|
|
print "($prop_name:=$value" . ($important ? " !IMPORTANT" : "") . ")";
|
|
}
|
|
|
|
$style->set_prop($prop_name, $value, $important, false);
|
|
}
|
|
|
|
if ($DEBUGCSS) {
|
|
print '_parse_properties]';
|
|
}
|
|
|
|
return $style;
|
|
}
|
|
|
|
/**
|
|
* parse selector + rulesets
|
|
*
|
|
* @param string $str CSS selectors and rulesets
|
|
* @param array $media_queries
|
|
*/
|
|
private function _parse_sections($str, $media_queries = [])
|
|
{
|
|
// Pre-process selectors: collapse all whitespace and strip whitespace
|
|
// around '>', '.', ':', '+', '~', '#'
|
|
$patterns = ["/\s+/", "/\s+([>.:+~#])\s+/"];
|
|
$replacements = [" ", "\\1"];
|
|
$DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
|
|
|
|
$sections = explode("}", $str);
|
|
if ($DEBUGCSS) print '[_parse_sections';
|
|
foreach ($sections as $sect) {
|
|
$i = mb_strpos($sect, "{", 0, "UTF-8");
|
|
if ($i === false) { continue; }
|
|
|
|
if ($DEBUGCSS) print '[section';
|
|
|
|
$selector_str = preg_replace($patterns, $replacements, mb_substr($sect, 0, $i, "UTF-8"));
|
|
$selectors = preg_split("/,(?![^\(]*\))/", $selector_str, 0, PREG_SPLIT_NO_EMPTY);
|
|
$style = $this->_parse_properties(trim(mb_substr($sect, $i + 1, null, "UTF-8")));
|
|
|
|
// Assign it to the selected elements
|
|
foreach ($selectors as $selector) {
|
|
$selector = trim($selector);
|
|
|
|
if ($selector === "") {
|
|
if ($DEBUGCSS) print '#empty#';
|
|
continue;
|
|
}
|
|
if ($DEBUGCSS) print '#' . $selector . '#';
|
|
//if ($DEBUGCSS) { if (strpos($selector,'p') !== false) print '!!!p!!!#'; }
|
|
|
|
//FIXME: tag the selector with a hash of the media query to separate it from non-conditional styles (?), xpath comments are probably not what we want to do here
|
|
if (count($media_queries) > 0) {
|
|
$style->set_media_queries($media_queries);
|
|
}
|
|
$this->add_style($selector, $style);
|
|
}
|
|
|
|
if ($DEBUGCSS) {
|
|
print 'section]';
|
|
}
|
|
}
|
|
|
|
if ($DEBUGCSS) {
|
|
print "_parse_sections]\n";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a CSS string containing quotes and escaped hex characters.
|
|
* https://www.w3.org/TR/CSS21/syndata.html#characters
|
|
*
|
|
* @param string $string The string to parse.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function parse_string(string $string): string
|
|
{
|
|
// Strip string quotes and escapes
|
|
$string = preg_replace('/^["\']|["\']$/', "", $string);
|
|
$string = preg_replace("/\\\\([^0-9a-fA-F])/", "\\1", $string);
|
|
|
|
// Convert escaped hex characters (e.g. \A => newline)
|
|
return preg_replace_callback(
|
|
"/\\\\([0-9a-fA-F]{1,6})\s?/",
|
|
function ($matches) { return Helpers::unichr(hexdec($matches[1])); },
|
|
$string
|
|
) ?? "";
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getDefaultStylesheet()
|
|
{
|
|
$options = $this->_dompdf->getOptions();
|
|
$rootDir = realpath($options->getRootDir());
|
|
return Helpers::build_url("file://", "", $rootDir, $rootDir . self::DEFAULT_STYLESHEET, $options->getChroot());
|
|
}
|
|
|
|
/**
|
|
* @param FontMetrics $fontMetrics
|
|
* @return $this
|
|
*/
|
|
public function setFontMetrics(FontMetrics $fontMetrics)
|
|
{
|
|
$this->fontMetrics = $fontMetrics;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return FontMetrics
|
|
*/
|
|
public function getFontMetrics()
|
|
{
|
|
return $this->fontMetrics;
|
|
}
|
|
|
|
/**
|
|
* dumps the entire stylesheet as a string
|
|
*
|
|
* Generates a string of each selector and associated style in the
|
|
* Stylesheet. Useful for debugging.
|
|
*
|
|
* @return string
|
|
*/
|
|
function __toString()
|
|
{
|
|
$str = "";
|
|
foreach ($this->_styles as $selector => $selector_styles) {
|
|
foreach ($selector_styles as $style) {
|
|
$str .= "$selector => " . $style->__toString() . "\n";
|
|
}
|
|
}
|
|
|
|
return $str;
|
|
}
|
|
}
|