PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.4
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.4
2.4.3 2.4.4 2.4.2 2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 All 60 releases
easy-invoice / vendor / dompdf / dompdf / src / Css / Stylesheet.php

Stylesheet.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.4, at vendor/dompdf/dompdf/src/Css/Stylesheet.php

1,919 lines 71.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package dompdf
4 * @link https://github.com/dompdf/dompdf
5 * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
6 */
7 namespace Dompdf\Css;
8
9 use DOMElement;
10 use DOMXPath;
11 use Dompdf\Css\Content\Url;
12 use Dompdf\Dompdf;
13 use Dompdf\Exception;
14 use Dompdf\FontMetrics;
15 use Dompdf\Frame\FrameTree;
16 use Dompdf\Helpers;
17
18 /**
19 * The master stylesheet class
20 *
21 * The Stylesheet class is responsible for parsing stylesheets and style
22 * tags/attributes. It also acts as a registry of the individual Style
23 * objects generated by the current set of loaded CSS files and style
24 * elements.
25 *
26 * @see Style
27 * @package dompdf
28 */
29 class Stylesheet
30 {
31 /**
32 * The location of the default built-in CSS file.
33 */
34 const DEFAULT_STYLESHEET = "/lib/res/html.css";
35
36 /**
37 * User agent stylesheet origin
38 *
39 * @var int
40 */
41 const ORIG_UA = 1;
42
43 /**
44 * User normal stylesheet origin
45 *
46 * @var int
47 */
48 const ORIG_USER = 2;
49
50 /**
51 * Author normal stylesheet origin
52 *
53 * @var int
54 */
55 const ORIG_AUTHOR = 3;
56
57 /**
58 * RegEx pattern representing a CSS string
59 *
60 * @var string
61 */
62 const PATTERN_CSS_STRING = '(?<CSS_STRING>(?<CSS_STRING_QUOTE>[\'"])(?<CSS_STRING_VALUE>.*?)(?<!\\\\)\g{CSS_STRING_QUOTE})';
63
64 /**
65 * RegEx pattern representing the CSS var() function
66 *
67 * @var string
68 */
69 const PATTERN_CSS_VAR_FN = "var\((([^()]|(?R))*)\)";
70
71 /**
72 * RegEx pattern representing the CSS url() function
73 *
74 * @var string
75 */
76 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*\))';
77
78 /**
79 * RegEx pattern representing the CSS local() function
80 *
81 * @var string
82 */
83 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*\))';
84
85 /**
86 * RegEx pattern representing a CSS media query
87 *
88 * @var string
89 */
90 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*)?\))))';
91
92 /*
93 * The highest possible specificity is 0x01000000 (and that is only for author
94 * stylesheets, as it is for inline styles). Origin precedence can be achieved by
95 * adding multiples of 0x10000000 to the actual specificity. Important
96 * declarations are handled in Style; though technically they should be handled
97 * here so that user important declarations can be made to take precedence over
98 * user important declarations, this doesn't matter in practice as Dompdf does
99 * not support user stylesheets, and user agent stylesheets can not include
100 * important declarations.
101 */
102 private static $_stylesheet_origins = [
103 self::ORIG_UA => 0x00000000, // user agent declarations
104 self::ORIG_USER => 0x10000000, // user normal declarations
105 self::ORIG_AUTHOR => 0x30000000, // author normal declarations
106 ];
107
108 /**
109 * Non-CSS presentational hints (i.e. HTML 4 attributes) are handled as if added
110 * to the beginning of an author stylesheet, i.e. anything in author stylesheets
111 * should override them.
112 */
113 const SPEC_NON_CSS = 0x20000000;
114
115 /**
116 * Current dompdf instance
117 *
118 * @var Dompdf
119 */
120 private $_dompdf;
121
122 /**
123 * Array of currently defined styles
124 *
125 * @var array<string, Style[]>
126 */
127 private $_styles;
128
129 /**
130 * Array of embedded files (dataURIs) found in the parsed CSS
131 *
132 * @var array<string>
133 */
134 private $_blobs;
135
136 /**
137 * Base protocol of the document being parsed
138 * Used to handle relative urls.
139 *
140 * @var string
141 */
142 private $_protocol = "";
143
144 /**
145 * Base hostname of the document being parsed
146 * Used to handle relative urls.
147 *
148 * @var string
149 */
150 private $_base_host = "";
151
152 /**
153 * Base path of the document being parsed
154 * Used to handle relative urls.
155 *
156 * @var string
157 */
158 private $_base_path = "";
159
160 /**
161 * The styles defined by @page rules
162 *
163 * @var array<Style>
164 */
165 private $_page_styles;
166
167 /**
168 * List of loaded files, used to prevent recursion
169 *
170 * @var array
171 */
172 private $_loaded_files;
173
174 /**
175 * Current stylesheet origin
176 *
177 * @var int
178 */
179 private $_current_origin = self::ORIG_UA;
180
181 /**
182 * Accepted CSS media types
183 * List of types and parsing rules for future extensions:
184 * http://www.w3.org/TR/REC-html40/types.html
185 * screen, tty, tv, projection, handheld, print, braille, aural, all
186 * The following are non standard extensions for undocumented specific environments.
187 * static, visual, bitmap, paged, dompdf
188 * Note, even though the generated pdf file is intended for print output,
189 * the desired content might be different (e.g. screen or projection view of html file).
190 * Therefore allow specification of content by dompdf setting Options::defaultMediaType.
191 * If given, replace media "print" by Options::defaultMediaType.
192 * (Previous version $ACCEPTED_MEDIA_TYPES = $ACCEPTED_GENERIC_MEDIA_TYPES + $ACCEPTED_DEFAULT_MEDIA_TYPE)
193 */
194 static $ACCEPTED_DEFAULT_MEDIA_TYPE = "print";
195 static $ACCEPTED_GENERIC_MEDIA_TYPES = ["all", "static", "visual", "bitmap", "paged", "dompdf"];
196 static $VALID_MEDIA_TYPES = ["all", "aural", "bitmap", "braille", "dompdf", "embossed", "handheld", "paged", "print", "projection", "screen", "speech", "static", "tty", "tv", "visual"];
197
198 /**
199 * @var FontMetrics
200 */
201 private $fontMetrics;
202
203 /**
204 * The class constructor.
205 *
206 * The base protocol, host & path are initialized to those of
207 * the current script.
208 */
209 function __construct(Dompdf $dompdf)
210 {
211 $this->_dompdf = $dompdf;
212 $this->setFontMetrics($dompdf->getFontMetrics());
213 $this->_styles = [];
214 $this->_loaded_files = [];
215 $script = __FILE__;
216 if (isset($_SERVER["SCRIPT_FILENAME"])) {
217 $script = $_SERVER["SCRIPT_FILENAME"];
218 }
219 list($this->_protocol, $this->_base_host, $this->_base_path) = Helpers::explode_url($script);
220 $this->_page_styles = ["base" => new Style($this)];
221 }
222
223 /**
224 * Set the base protocol
225 *
226 * @param string $protocol
227 */
228 function set_protocol(string $protocol)
229 {
230 $this->_protocol = $protocol;
231 }
232
233 /**
234 * Set the base host
235 *
236 * @param string $host
237 */
238 function set_host(string $host)
239 {
240 $this->_base_host = $host;
241 }
242
243 /**
244 * Set the base path
245 *
246 * @param string $path
247 */
248 function set_base_path(string $path)
249 {
250 $this->_base_path = $path;
251 }
252
253 /**
254 * Return the Dompdf object
255 *
256 * @return Dompdf
257 */
258 function get_dompdf()
259 {
260 return $this->_dompdf;
261 }
262
263 /**
264 * Return the base protocol for this stylesheet
265 *
266 * @return string
267 */
268 function get_protocol()
269 {
270 return $this->_protocol;
271 }
272
273 /**
274 * Return the base host for this stylesheet
275 *
276 * @return string
277 */
278 function get_host()
279 {
280 return $this->_base_host;
281 }
282
283 /**
284 * Return the base path for this stylesheet
285 *
286 * @return string
287 */
288 function get_base_path()
289 {
290 return $this->_base_path;
291 }
292
293 /**
294 * Get all registered styles as an associative array, indexed by selector.
295 *
296 * @return array<string, Style[]>
297 */
298 public function get_styles(): array
299 {
300 return $this->_styles;
301 }
302
303 /**
304 * Return the array of page styles
305 *
306 * @return Style[]
307 */
308 function get_page_styles()
309 {
310 return $this->_page_styles;
311 }
312
313 /**
314 * Create a new Style object associated with this stylesheet
315 *
316 * @return Style
317 */
318 function create_style(): Style
319 {
320 return new Style($this, $this->_current_origin);
321 }
322
323 /**
324 * Add a new Style object to the stylesheet
325 *
326 * The style's origin is changed to the current origin of the stylesheet.
327 *
328 * @param string $key the Style's selector
329 * @param Style $style the Style to be added
330 */
331 function add_style(string $key, Style $style): void
332 {
333 if (!isset($this->_styles[$key])) {
334 $this->_styles[$key] = [];
335 }
336
337 $style->set_origin($this->_current_origin);
338 $this->_styles[$key][] = $style;
339 }
340
341 /**
342 * load and parse a CSS string
343 *
344 * @param string $css
345 * @param int $origin
346 */
347 function load_css(&$css, $origin = self::ORIG_AUTHOR)
348 {
349 if ($origin) {
350 $this->_current_origin = $origin;
351 }
352 $this->_parse_css($css);
353 }
354
355 /**
356 * load and parse a CSS file
357 *
358 * @param string $file
359 * @param int $origin
360 */
361 function load_css_file($file, $origin = self::ORIG_AUTHOR)
362 {
363 if ($origin) {
364 $this->_current_origin = $origin;
365 }
366
367 // Prevent circular references
368 if (isset($this->_loaded_files[$file])) {
369 return;
370 }
371 $this->_loaded_files[$file] = true;
372
373 $parsed_url = Helpers::explode_url($file);
374 $protocol = $parsed_url["protocol"];
375
376 if ($file !== $this->getDefaultStylesheet()) {
377 $options = $this->_dompdf->getOptions();
378 $allowed_protocols = $options->getAllowedProtocols();
379 if (!array_key_exists($protocol, $allowed_protocols)) {
380 Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The communication protocol is not supported.", __FILE__, __LINE__);
381 return;
382 }
383 foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
384 [$result, $message] = $rule($file);
385 if (!$result) {
386 Helpers::record_warnings(E_USER_WARNING, "Error loading $file: $message", __FILE__, __LINE__);
387 return;
388 }
389 }
390 }
391
392 if (strpos($file, "data:") === 0) {
393 $parsed = Helpers::parse_data_uri($file);
394 $css = $parsed["data"];
395 } else {
396 [$css, $http_response_header] = Helpers::getFileContent($file, $this->_dompdf->getHttpContext());
397
398 $good_mime_type = true;
399 if (isset($http_response_header) && !$this->_dompdf->getQuirksmode()) {
400 foreach ($http_response_header as $_header) {
401 if (preg_match("@Content-Type:\s*([\w/]+)@i", $_header, $matches) &&
402 ($matches[1] !== "text/css")
403 ) {
404 $good_mime_type = false;
405 }
406 }
407 }
408 if (!$good_mime_type || $css === null) {
409 Helpers::record_warnings(E_USER_WARNING, "Unable to load css file $file", __FILE__, __LINE__);
410 return;
411 }
412
413 [$this->_protocol, $this->_base_host, $this->_base_path] = $parsed_url;
414 }
415
416 $this->_parse_css($css);
417 }
418
419 /**
420 * @link https://www.w3.org/TR/CSS21/cascade.html#specificity
421 *
422 * @param string $selector
423 * @param int $origin
424 * - Stylesheet::ORIG_UA: user agent style sheet
425 * - Stylesheet::ORIG_USER: user style sheet
426 * - Stylesheet::ORIG_AUTHOR: author style sheet
427 *
428 * @return int
429 */
430 protected function specificity(string $selector, int $origin = self::ORIG_AUTHOR): int
431 {
432 $a = ($selector === "!attr") ? 1 : 0;
433
434 $b = min(mb_substr_count($selector, "#"), 255);
435
436 $c = min(mb_substr_count($selector, ".") +
437 mb_substr_count($selector, "[") +
438 mb_substr_count($selector, ":") -
439 2 * mb_substr_count($selector, "::"), 255);
440
441 $d = min(mb_substr_count($selector, " ") +
442 mb_substr_count($selector, ">") +
443 mb_substr_count($selector, "+") +
444 mb_substr_count($selector, "~") -
445 mb_substr_count($selector, "~=") +
446 mb_substr_count($selector, "::"), 255);
447
448 //If a normal element name is at the beginning of the string,
449 //a leading whitespace might have been removed on whitespace collapsing and removal
450 //therefore there might be one whitespace less as selected element names
451 //this can lead to a too small specificity
452 //see selectorToXpath
453
454 if (!in_array($selector[0], [" ", ">", ".", "#", "+", "~", ":", "["], true) && $selector !== "*") {
455 $d++;
456 }
457
458 if ($this->_dompdf->getOptions()->getDebugCss()) {
459 /*DEBUGCSS*/
460 print "<pre>\n";
461 /*DEBUGCSS*/
462 printf("specificity(): 0x%08x \"%s\"\n", self::$_stylesheet_origins[$origin] + (($a << 24) | ($b << 16) | ($c << 8) | ($d)), $selector);
463 /*DEBUGCSS*/
464 print "</pre>";
465 }
466
467 return self::$_stylesheet_origins[$origin] + (($a << 24) | ($b << 16) | ($c << 8) | ($d));
468 }
469
470 /**
471 * Converts a CSS selector to an XPath query.
472 *
473 * @param string $selector
474 * @param bool $firstPass
475 *
476 * @return array|null
477 */
478 protected function selectorToXpath(string $selector, bool $firstPass = false): ?array
479 {
480 // Collapse white space and strip whitespace around delimiters
481 //$search = array("/\\s+/", "/\\s+([.>#+:])\\s+/");
482 //$replace = array(" ", "\\1");
483 //$selector = preg_replace($search, $replace, trim($selector));
484
485 // Initial query, always expanded to // below (non-absolute)
486 $query = "/";
487
488 // Will contain :before and :after
489 $pseudo_elements = [];
490
491 // Parse the selector
492 //$s = preg_split("/([ :>.#+])/", $selector, -1, PREG_SPLIT_DELIM_CAPTURE);
493
494 $delimiters = [" ", ">", ".", "#", "+", "~", ":", "[", "("];
495
496 // Add an implicit space at the beginning of the selector if there is no
497 // delimiter there already.
498 if (!in_array($selector[0], $delimiters, true)) {
499 $selector = " $selector";
500 }
501
502 $name = "*";
503 $len = mb_strlen($selector, "UTF-8");
504 $i = 0;
505
506 while ($i < $len) {
507
508 $s = $selector[$i];
509 $i++;
510
511 // Eat characters up to the next delimiter
512 $tok = "";
513 $escape = false;
514 $in_attr = false;
515 $in_func = false;
516
517 while ($i < $len) {
518 $c = $selector[$i];
519 $c_prev = $selector[$i - 1];
520
521 if ($c_prev === "\\" && !$escape) {
522 $escape = true;
523 } elseif ($escape === true) {
524 $escape = false;
525 }
526
527 if (!$escape && !$in_func && !$in_attr && in_array($c, $delimiters, true) && !($c === $c_prev && $c === ":")) {
528 break;
529 }
530
531 if ($c_prev === "[") {
532 $in_attr = true;
533 }
534 if ($c_prev === "(") {
535 $in_func = true;
536 }
537
538 $tok .= $selector[$i++];
539
540 if (!$escape && $in_attr && $c === "]") {
541 $in_attr = false;
542 break;
543 }
544 if (!$escape && $in_func && $c === ")") {
545 $in_func = false;
546 break;
547 }
548 }
549 $tok = $this->parse_string($tok);
550
551
552 switch ($s) {
553
554 case " ":
555 case ">":
556 // All elements matching the next token that are descendants
557 // or children of the current token
558 // https://www.w3.org/TR/selectors-3/#descendant-combinators
559 // https://www.w3.org/TR/selectors-3/#child-combinators
560 $expr = $s === " " ? "descendant" : "child";
561
562 // Tag names are case-insensitive
563 $name = $tok === "" ? "*" : strtolower($tok);
564 $query .= "/$expr::$name";
565 break;
566
567 case "+":
568 // Next-sibling combinator
569 // https://www.w3.org/TR/selectors-3/#sibling-combinators
570
571 // Tag names are case-insensitive
572 $name = $tok === "" ? "*" : strtolower($tok);
573 $query .= "/following-sibling::*[1]";
574
575 if ($name !== "*") {
576 $query .= "[name() = '$name']";
577 }
578 break;
579
580 case "~":
581 // Subsequent-sibling combinator
582 // https://www.w3.org/TR/selectors-3/#sibling-combinators
583
584 // Tag names are case-insensitive
585 $name = $tok === "" ? "*" : strtolower($tok);
586 $query .= "/following-sibling::$name";
587 break;
588
589 case "#":
590 // All elements matching the current token with id equal
591 // to the _next_ token
592 // https://www.w3.org/TR/selectors-3/#id-selectors
593 if ($query === "/") {
594 $query .= "/*";
595 }
596
597 $query .= "[@id=\"$tok\"]";
598 break;
599
600 case ".":
601 // All elements matching the current token with a class
602 // equal to the _next_ token
603 // https://www.w3.org/TR/selectors-3/#class-html
604 if ($query === "/") {
605 $query .= "/*";
606 }
607
608 // Match multiple classes: $tok contains the current selected
609 // class. Search for class attributes with class="$tok",
610 // class=".* $tok .*" and class=".* $tok"
611
612 // This doesn't work because libxml only supports XPath 1.0...
613 //$query .= "[matches(@$attr,\"^{$tok}\$|^{$tok}[ ]+|[ ]+{$tok}\$|[ ]+{$tok}[ ]+\")]";
614
615 $query .= "[contains(concat(' ', normalize-space(@class), ' '), concat(' ', '$tok', ' '))]";
616 break;
617
618 case ":":
619 if ($query === "/") {
620 $query .= "/*";
621 }
622
623 $last = false;
624
625 // Pseudo-classes
626 switch ($tok) {
627
628 case "root":
629 $query .= "[not(parent::*)]";
630 break;
631
632 case "first-child":
633 $query .= "[not(preceding-sibling::*)]";
634 break;
635
636 case "last-child":
637 $query .= "[not(following-sibling::*)]";
638 break;
639
640 case "only-child":
641 $query .= "[not(preceding-sibling::*) and not(following-sibling::*)]";
642 break;
643
644 // https://www.w3.org/TR/selectors-3/#nth-child-pseudo
645 /** @noinspection PhpMissingBreakStatementInspection */
646 case "nth-last-child":
647 $last = true;
648 case "nth-child":
649 $p = $i + 1;
650 $nth = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
651 $position = $last
652 ? "(count(following-sibling::*) + 1)"
653 : "(count(preceding-sibling::*) + 1)";
654
655 $condition = $this->selectorAnPlusB($nth, $position);
656 $query .= "[$condition]";
657 break;
658
659 // TODO: `*:first-of-type`, `*:nth-of-type` etc.
660 // (without fixed element name) are treated equivalent
661 // to their `:*-child` counterparts here. They might
662 // not be properly expressible in XPath 1.0
663 case "first-of-type":
664 $query .= "[not(preceding-sibling::$name)]";
665 break;
666
667 case "last-of-type":
668 $query .= "[not(following-sibling::$name)]";
669 break;
670
671 case "only-of-type":
672 $query .= "[not(preceding-sibling::$name) and not(following-sibling::$name)]";
673 break;
674
675 // https://www.w3.org/TR/selectors-3/#nth-of-type-pseudo
676 /** @noinspection PhpMissingBreakStatementInspection */
677 case "nth-last-of-type":
678 $last = true;
679 case "nth-of-type":
680 $p = $i + 1;
681 $nth = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
682 $position = $last
683 ? "(count(following-sibling::$name) + 1)"
684 : "(count(preceding-sibling::$name) + 1)";
685
686 $condition = $this->selectorAnPlusB($nth, $position);
687 $query .= "[$condition]";
688 break;
689
690 // https://www.w3.org/TR/selectors-4/#empty-pseudo
691 case "empty":
692 $query .= "[not(*) and not(normalize-space())]";
693 break;
694
695 // TODO: bit of a hack attempt at matches support, currently only matches against elements
696 case "matches":
697 $p = $i + 1;
698 $matchList = trim(mb_substr($selector, $p, mb_strpos($selector, ")", $i, "UTF-8") - $p, "UTF-8"));
699
700 // Tag names are case-insensitive
701 $elements = array_map("trim", explode(",", strtolower($matchList)));
702 foreach ($elements as &$element) {
703 $element = "name() = '$element'";
704 }
705
706 $query .= "[" . implode(" or ", $elements) . "]";
707 break;
708
709 // https://www.w3.org/TR/selectors-3/#UIstates
710 case "disabled":
711 case "checked":
712 $query .= "[@$tok]";
713 break;
714
715 case "enabled":
716 $query .= "[not(@disabled)]";
717 break;
718
719 // https://www.w3.org/TR/selectors-3/#dynamic-pseudos
720 // https://www.w3.org/TR/selectors-4/#the-any-link-pseudo
721 case "link":
722 case "any-link":
723 $query .= "[@href]";
724 break;
725
726 // N/A
727 case "visited":
728 case "hover":
729 case "active":
730 case "focus":
731 case "focus-visible":
732 case "focus-within":
733 $query .= "[false()]";
734 break;
735
736 // https://www.w3.org/TR/selectors-3/#first-line
737 // https://www.w3.org/TR/selectors-3/#first-letter
738 case "first-line":
739 case ":first-line":
740 case "first-letter":
741 case ":first-letter":
742 // TODO
743 $el = ltrim($tok, ":");
744 $pseudo_elements[$el] = true;
745 break;
746
747 // https://www.w3.org/TR/selectors-3/#gen-content
748 case "before":
749 case ":before":
750 case "after":
751 case ":after":
752 $pos = ltrim($tok, ":");
753 $pseudo_elements[$pos] = true;
754 if (!$firstPass) {
755 $query .= "/*[@$pos]";
756 }
757 break;
758
759 // Invalid or unsupported pseudo-class or pseudo-element
760 default:
761 return null;
762 }
763
764 break;
765
766 case "[":
767 // Attribute selectors. All with an attribute matching the
768 // following token(s)
769 // https://www.w3.org/TR/selectors-3/#attribute-selectors
770 if ($query === "/") {
771 $query .= "/*";
772 }
773
774 $attr_delimiters = ["=", "]", "~", "|", "$", "^", "*"];
775 $tok_len = mb_strlen($tok, "UTF-8");
776 $j = 0;
777
778 $attr = "";
779 $op = "";
780 $value = "";
781
782 while ($j < $tok_len) {
783 if (in_array($tok[$j], $attr_delimiters, true)) {
784 break;
785 }
786 $attr .= $tok[$j++];
787 }
788
789 if ($attr === "") {
790 // Selector invalid: Missing attribute name
791 return null;
792 }
793
794 if (!isset($tok[$j])) {
795 // Selector invalid: Missing ] or operator
796 return null;
797 }
798
799 switch ($tok[$j]) {
800
801 case "~":
802 case "|":
803 case "^":
804 case "$":
805 case "*":
806 $op .= $tok[$j++];
807
808 if (!isset($tok[$j]) || $tok[$j] !== "=") {
809 // Selector invalid: Incomplete attribute operator
810 return null;
811 }
812
813 $op .= $tok[$j];
814 break;
815
816 case "=":
817 $op = "=";
818 break;
819
820 }
821
822 // Read the attribute value, if required
823 if ($op !== "") {
824 $j++;
825 while ($j < $tok_len) {
826 if ($tok[$j] === "]") {
827 break;
828 }
829 $value .= $tok[$j++];
830 }
831 }
832
833 if (!isset($tok[$j])) {
834 // Selector invalid: Missing ]
835 return null;
836 }
837
838 $value = trim($value, "\"'");
839
840 switch ($op) {
841
842 case "":
843 $query .= "[@$attr]";
844 break;
845
846 case "=":
847 $query .= "[@$attr=\"$value\"]";
848 break;
849
850 case "~=":
851 // FIXME: this will break if $value contains quoted strings
852 // (e.g. [type~="a b c" "d e f"])
853 $query .= $value !== "" && !preg_match("/\s+/", $value)
854 ? "[contains(concat(' ', normalize-space(@$attr), ' '), concat(' ', \"$value\", ' '))]"
855 : "[false()]";
856 break;
857
858 case "|=":
859 $values = explode("-", $value);
860 $query .= "[";
861
862 foreach ($values as $val) {
863 $query .= "starts-with(@$attr, \"$val\") or ";
864 }
865
866 $query = rtrim($query, " or ") . "]";
867 break;
868
869 case "^=":
870 $query .= $value !== ""
871 ? "[starts-with(@$attr,\"$value\")]"
872 : "[false()]";
873 break;
874
875 case "$=":
876 $query .= $value !== ""
877 ? "[substring(@$attr, string-length(@$attr)-" . (strlen($value) - 1) . ")=\"$value\"]"
878 : "[false()]";
879 break;
880
881 case "*=":
882 $query .= $value !== ""
883 ? "[contains(@$attr,\"$value\")]"
884 : "[false()]";
885 break;
886 }
887
888 break;
889 }
890 }
891
892 return ["query" => $query, "pseudo_elements" => $pseudo_elements];
893 }
894
895 /**
896 * Parse an `nth-child` expression of the form `an+b`, `odd`, or `even`.
897 *
898 * @param string $expr
899 * @param string $position
900 *
901 * @return string
902 *
903 * @link https://www.w3.org/TR/selectors-3/#nth-child-pseudo
904 */
905 protected function selectorAnPlusB(string $expr, string $position): string
906 {
907 // odd
908 if ($expr === "odd") {
909 return "($position mod 2) = 1";
910 } // even
911 elseif ($expr === "even") {
912 return "($position mod 2) = 0";
913 } // b
914 elseif (preg_match("/^\d+$/", $expr)) {
915 return "$position = $expr";
916 }
917
918 // an+b
919 // https://github.com/tenderlove/nokogiri/blob/master/lib/nokogiri/css/xpath_visitor.rb
920 $expr = preg_replace("/\s/", "", $expr);
921 if (!preg_match("/^(?P<a>-?[0-9]*)?n(?P<b>[-+]?[0-9]+)?$/", $expr, $matches)) {
922 return "false()";
923 }
924
925 $a = (isset($matches["a"]) && $matches["a"] !== "") ? ($matches["a"] !== "-" ? intval($matches["a"]) : -1) : 1;
926 $b = (isset($matches["b"]) && $matches["b"] !== "") ? intval($matches["b"]) : 0;
927
928 if ($b === 0) {
929 return "($position mod $a) = 0";
930 } else {
931 $compare = ($a < 0) ? "<=" : ">=";
932 $b2 = -$b;
933 if ($b2 >= 0) {
934 $b2 = "+$b2";
935 }
936 return "($position $compare $b) and ((($position $b2) mod " . abs($a) . ") = 0)";
937 }
938 }
939
940 /**
941 * applies all current styles to a particular document tree
942 *
943 * apply_styles() applies all currently loaded styles to the provided
944 * {@link FrameTree}. Aside from parsing CSS, this is the main purpose
945 * of this class.
946 *
947 * @param FrameTree $tree
948 */
949 function apply_styles(FrameTree $tree)
950 {
951 // Use XPath to select nodes. This would be easier if we could attach
952 // Frame objects directly to DOMNodes using the setUserData() method, but
953 // we can't do that just yet. Instead, we set a _node attribute_ in
954 // Frame->set_id() and use that as a handle on the Frame object via
955 // FrameTree::$_registry.
956
957 // We create a scratch array of styles indexed by frame id. Once all
958 // styles have been assigned, we order the cached styles by specificity
959 // and create a final style object to assign to the frame.
960
961 // FIXME: this is not particularly robust...
962
963 $styles = [];
964 $xp = new DOMXPath($tree->get_dom());
965 $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
966
967 // Add generated content
968 foreach ($this->_styles as $selector => $selector_styles) {
969 if (strpos($selector, ":before") === false && strpos($selector, ":after") === false) {
970 continue;
971 }
972
973 $query = $this->selectorToXpath($selector, true);
974 if ($query === null) {
975 Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
976 continue;
977 }
978
979 // Retrieve the nodes, limit to body for generated content
980 // TODO: If we use a context node can we remove the leading dot?
981 $nodes = @$xp->query('.' . $query["query"]);
982 if ($nodes === false) {
983 Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
984 continue;
985 }
986
987 foreach ($selector_styles as $style) {
988 foreach ($nodes as $node) {
989 // Only DOMElements get styles
990 if (!($node instanceof DOMElement)) {
991 continue;
992 }
993
994 foreach (array_keys($query["pseudo_elements"], true, true) as $pos) {
995 // Do not add a new pseudo element if another one already matched
996 if ($node->hasAttribute("dompdf_{$pos}_frame_id")) {
997 continue;
998 }
999
1000 $content = $style->content;
1001
1002 // Do not create non-displayed before/after pseudo
1003 // elements. Since styles have not been inherited yet,
1004 // a specified value of `inherit` will always be treated
1005 // as `normal` here. This is fine according to the
1006 // CSS 2.1 spec, as any value computes to `normal` on
1007 // regular elements
1008 // https://www.w3.org/TR/CSS21/generate.html#content
1009 // https://www.w3.org/TR/CSS21/generate.html#undisplayed-counters
1010 if ($content === "normal" || $content === "none") {
1011 $specified = $style->get_specified("content");
1012 if (!\preg_match("/". self::PATTERN_CSS_VAR_FN . "/", $specified)) {
1013 continue;
1014 } else {
1015 $content = [];
1016 }
1017 }
1018
1019 // https://www.w3.org/TR/css-content-3/#content-property
1020 $single = count($content) === 1 ? $content[0] : null;
1021
1022 if ($single instanceof Url) {
1023 $src = $this->resolve_url("url($single->url)", true);
1024 $new_node = $node->ownerDocument->createElement("img_generated");
1025 $new_node->setAttribute("src", $src);
1026 } else {
1027 $new_node = $node->ownerDocument->createElement("dompdf_generated");
1028 }
1029
1030 $new_node->setAttribute($pos, $pos);
1031 $new_frame_id = $tree->insert_node($node, $new_node, $pos);
1032 $node->setAttribute("dompdf_{$pos}_frame_id", $new_frame_id);
1033 }
1034 }
1035 }
1036 }
1037
1038 // Apply all styles in stylesheet
1039 foreach ($this->_styles as $selector => $selector_styles) {
1040 $query = $this->selectorToXpath($selector);
1041 if ($query === null) {
1042 Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
1043 continue;
1044 }
1045
1046 // Retrieve the nodes
1047 $nodes = @$xp->query($query["query"]);
1048 if ($nodes === false) {
1049 Helpers::record_warnings(E_USER_WARNING, "The CSS selector '$selector' is not valid", __FILE__, __LINE__);
1050 continue;
1051 }
1052
1053 foreach ($selector_styles as $style) {
1054 $spec = $this->specificity($selector, $style->get_origin());
1055
1056 foreach ($nodes as $node) {
1057 // Only DOMElements get styles
1058 if (!($node instanceof DOMElement)) {
1059 continue;
1060 }
1061
1062 $id = $node->getAttribute("frame_id");
1063
1064 // Assign the current style to the scratch array
1065 $styles[$id][$spec][] = $style;
1066 }
1067 }
1068 }
1069
1070 // Set the page width, height, and orientation based on the canvas paper size
1071 $canvas = $this->_dompdf->getCanvas();
1072 $paper_width = $canvas->get_width();
1073 $paper_height = $canvas->get_height();
1074 $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
1075
1076 if ($this->_page_styles["base"] && is_array($this->_page_styles["base"]->size)) {
1077 $paper_width = $this->_page_styles['base']->size[0];
1078 $paper_height = $this->_page_styles['base']->size[1];
1079 $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
1080 }
1081
1082 // Now create the styles and assign them to the appropriate frames. (We
1083 // iterate over the tree using an implicit FrameTree iterator.)
1084 $root_flg = false;
1085 foreach ($tree as $frame) {
1086 // Helpers::pre_r($frame->get_node()->nodeName . ":");
1087 if (!$root_flg && $this->_page_styles["base"]) {
1088 $style = $this->_page_styles["base"];
1089 } else {
1090 $style = $this->create_style();
1091 }
1092
1093 // Find nearest DOMElement parent
1094 $p = $frame;
1095 while ($p = $p->get_parent()) {
1096 if ($p->get_node()->nodeType === XML_ELEMENT_NODE) {
1097 break;
1098 }
1099 }
1100
1101 // Styles can only be applied directly to DOMElements; anonymous
1102 // frames inherit from their parent
1103 if ($frame->get_node()->nodeType !== XML_ELEMENT_NODE) {
1104 $style->inherit($p ? $p->get_style() : null);
1105 $frame->set_style($style);
1106 continue;
1107 }
1108
1109 $id = $frame->get_id();
1110
1111 // Handle HTML 4.0 attributes
1112 AttributeTranslator::translate_attributes($frame);
1113 if (($str = $frame->get_node()->getAttribute(AttributeTranslator::$_style_attr)) !== "") {
1114 $styles[$id][self::SPEC_NON_CSS][] = $this->_parse_properties($str);
1115 }
1116
1117 // Locate any additional style attributes
1118 if (($str = $frame->get_node()->getAttribute("style")) !== "") {
1119 // Destroy CSS comments
1120 $str = preg_replace("'/\*.*?\*/'si", "", $str);
1121
1122 $spec = $this->specificity("!attr", self::ORIG_AUTHOR);
1123 $styles[$id][$spec][] = $this->_parse_properties($str);
1124 }
1125
1126 // Grab the applicable styles
1127 if (isset($styles[$id])) {
1128
1129 /** @var array[][] $applied_styles */
1130 $applied_styles = $styles[$id];
1131
1132 // Sort by specificity
1133 ksort($applied_styles);
1134
1135 if ($DEBUGCSS) {
1136 $debug_nodename = $frame->get_node()->nodeName;
1137 print "<pre>\n$debug_nodename [\n";
1138 foreach ($applied_styles as $spec => $arr) {
1139 printf(" specificity 0x%08x\n", $spec);
1140 /** @var Style $s */
1141 foreach ($arr as $s) {
1142 print " [\n";
1143 $s->debug_print();
1144 print " ]\n";
1145 }
1146 }
1147 }
1148
1149 // Merge the new styles with the inherited styles
1150 $acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES;
1151 $acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType();
1152
1153 foreach ($applied_styles as $arr) {
1154 /** @var Style $s */
1155 foreach ($arr as $s) {
1156 $media_queries = $s->get_media_queries();
1157 if (count($media_queries) > 0) {
1158 $media_query_match = false;
1159 foreach ($media_queries as $media_query_group) {
1160 foreach ($media_query_group as $media_query) {
1161 list($media_query_feature, $media_query_value, $media_query_operator) = $media_query;
1162 switch ($media_query_feature) {
1163 case "height":
1164 $feature_match = $paper_height === (float)$style->length_in_pt($media_query_value);
1165 break;
1166 case "min-height":
1167 $feature_match = $paper_height >= (float)$style->length_in_pt($media_query_value);
1168 break;
1169 case "max-height":
1170 $feature_match = $paper_height <= (float)$style->length_in_pt($media_query_value);
1171 break;
1172 case "width":
1173 $feature_match = $paper_width === (float)$style->length_in_pt($media_query_value);
1174 break;
1175 case "min-width":
1176 $feature_match = $paper_width >= (float)$style->length_in_pt($media_query_value);
1177 break;
1178 case "max-width":
1179 $feature_match = $paper_width <= (float)$style->length_in_pt($media_query_value);
1180 break;
1181 case "orientation":
1182 $feature_match = $paper_orientation === $media_query_value;
1183 break;
1184 case "type":
1185 $feature_match = in_array($media_query_value, $acceptedmedia, true);
1186 break;
1187 default:
1188 Helpers::record_warnings(E_USER_WARNING, "Unknown media query: $media_query_feature", __FILE__, __LINE__);
1189 continue (2); // unknown query, move to the next grouping
1190 }
1191 $negate = $media_query_operator === "not";
1192 if ($negate xor !$feature_match) {
1193 continue (2); // failed query match, move to the next grouping
1194 }
1195 }
1196 $media_query_match = true;
1197 }
1198 if (!$media_query_match) {
1199 continue;
1200 }
1201 }
1202 $style->merge($s);
1203 }
1204 }
1205 }
1206
1207 // Handle inheritance
1208 if ($p && $DEBUGCSS) {
1209 print " inherit [\n";
1210 $p->get_style()->debug_print();
1211 print " ]\n";
1212 }
1213
1214 $style->inherit($p ? $p->get_style() : null);
1215
1216 if ($DEBUGCSS) {
1217 print " DomElementStyle [\n";
1218 $style->debug_print();
1219 print " ]\n";
1220 print "]\n</pre>";
1221 }
1222
1223 $style->clear_important();
1224 $frame->set_style($style);
1225
1226 if (!$root_flg && $this->_page_styles["base"]) {
1227 $root_flg = true;
1228
1229 // set the page width, height, and orientation based on the parsed page style
1230 if ($style->size !== "auto") {
1231 list($paper_width, $paper_height) = $style->size;
1232 }
1233 $paper_width = $paper_width - (float)$style->length_in_pt($style->margin_left) - (float)$style->length_in_pt($style->margin_right);
1234 $paper_height = $paper_height - (float)$style->length_in_pt($style->margin_top) - (float)$style->length_in_pt($style->margin_bottom);
1235 $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
1236 }
1237 }
1238
1239 // We're done! Clean out the registry of all styles since we
1240 // won't be needing this later.
1241 foreach (array_keys($this->_styles) as $key) {
1242 $this->_styles[$key] = null;
1243 unset($this->_styles[$key]);
1244 }
1245 }
1246
1247 /**
1248 * parse a CSS string using a regex parser
1249 * Called by {@link Stylesheet::parse_css()}
1250 *
1251 * @param string $str
1252 */
1253 private function _parse_css($str, $media_queries = [])
1254 {
1255 $str = trim($str);
1256
1257 // Destroy comments and remove HTML comments
1258 $css = preg_replace([
1259 "'/\*.*?\*/'si",
1260 "/^<!--/",
1261 "/-->$/"
1262 ], "", $str);
1263
1264 // shim constants for string interpolation
1265 $pattern_atimport_string = str_replace("CSS_STRING", "CSS_ATIMPORT_STRING", self::PATTERN_CSS_STRING);
1266 $pattern_atimport_url = str_replace("CSS_URL_FN", "CSS_ATIMPORT_URL_FN", self::PATTERN_CSS_URL_FN);
1267 $pattern_media_query = self::PATTERN_MEDIA_QUERY;
1268
1269 // Something more legible:
1270 // ... does not handle '{' within strings, e.g. [attr="string {}"]
1271 $re = <<<EOL
1272 /
1273 # Skip leading whitespace
1274 \s*
1275
1276 # Match at-rules
1277 (?<CSS_ATRULE>@(?<CSS_ATRULE_IDENTIFIER>
1278 (?<CSS_ATFONT>font-face)
1279 |(?<CSS_ATIMPORT>import)
1280 |(?<CSS_ATMEDIA>media)
1281 |(?<CSS_ATPAGE>page)
1282 |(?<CSS_AT>[\w-]*)
1283 ))?
1284
1285 # Branch to process segment following at-rule match
1286 (?(CSS_ATRULE)(?:
1287 (?(CSS_ATFONT)\s*{(?<CSS_ATFONT_BODY>.*?)})
1288 (?(CSS_ATIMPORT)\s*(?<CSS_ATIMPORT_RULE>
1289 (?<CSS_ATIMPORT_URL>
1290 {$pattern_atimport_string}
1291 |{$pattern_atimport_url}
1292 )
1293 (?<CSS_ATIMPORT_MEDIA_QUERY>.*?)
1294 );)
1295 (?(CSS_ATMEDIA)\s*(?<CSS_ATMEDIA_RULE>[^{]*){(?<CSS_ATMEDIA_BODY> (?:[^{}]*|(?R) \s*)+? )})
1296 (?(CSS_ATPAGE)\s*(?<CSS_ATPAGE_RULE>[^{]*){(?<CSS_ATPAGE_BODY>.*?)})
1297 (?(CSS_AT)\s*([^{;]*)(;|{(?<CSS_AT_BODY> (?:[^{}]*|(?R) \s*)+? )}))
1298 )
1299
1300 # Branch to match regular rules (not preceded by '@')
1301 |(?<CSS_RULESET>[^{]*{[^}]*}))
1302 /isx
1303 EOL;
1304
1305 // replace data URIs with blob URIs
1306 $offset = 0;
1307 while ($offset < strlen($css) && ($start = strpos($css, "data:", $offset)) !== false) {
1308 $len = 0;
1309 $prev = $css[$start - 1];
1310 $pattern = "/(?<!\\\\)['\")]/";
1311 switch ($prev) {
1312 case "'":
1313 $pattern = "/(?<!\\\\)'/";
1314 break;
1315 case "\"":
1316 $pattern = "/(?<!\\\\)\"/";
1317 break;
1318 case "(":
1319 $pattern = "/(?<!\\\\)\\)/";
1320 break;
1321 default:
1322 $offset = $start + 5;
1323 continue (2);
1324 }
1325 if (preg_match($pattern, $css, $matches, PREG_OFFSET_CAPTURE, $start)) {
1326 $len = $matches[0][1] - $start;
1327 }
1328 if ($len <= 0) {
1329 $offset = $start + 5;
1330 continue;
1331 }
1332 $data_uri = substr($css, $start, $len);
1333 $data_uri_hash = md5($data_uri);
1334 $this->_blobs[$data_uri_hash] = $data_uri;
1335 $css = substr($css, 0, $start) . "blob://" . $data_uri_hash . ($len > 0 ? substr($css, $start + $len) : "");
1336 $offset = $start + 7 + strlen($data_uri_hash);
1337 }
1338
1339 $matches = [];
1340 if (preg_match_all($re, $css, $matches, PREG_SET_ORDER) === false || count($matches) === 0) {
1341 global $_dompdf_warnings;
1342 $_dompdf_warnings[] = "Unable to parse CSS that starts with: " . substr($str, 0, 100);
1343 return;
1344 }
1345
1346 $media_query_regex = "/{$pattern_media_query}/isx";
1347 $accepted_media = self::$ACCEPTED_GENERIC_MEDIA_TYPES;
1348 $accepted_media[] = $this->_dompdf->getOptions()->getDefaultMediaType();
1349 foreach ($matches as $match) {
1350 if ($match["CSS_ATRULE_IDENTIFIER"] !== "") {
1351 $atrule_identifier = strtolower($match["CSS_ATRULE_IDENTIFIER"]);
1352 // Handle @rules
1353 switch ($atrule_identifier) {
1354
1355 case "import":
1356 $this->_parse_import($match["CSS_ATIMPORT_URL"], $match["CSS_ATIMPORT_MEDIA_QUERY"]);
1357 break;
1358
1359 case "media":
1360 $parsed_media_queries = [];
1361 $media_query_rules = preg_split("/\s*(,|\Wor\W)\s*/", mb_strtolower(trim($match["CSS_ATMEDIA_RULE"])));
1362 foreach ($media_query_rules as $media_query_rule) {
1363 $media_query_matches = [];
1364 if (preg_match_all($media_query_regex, $media_query_rule, $media_query_matches, PREG_SET_ORDER) === false) {
1365 continue;
1366 }
1367
1368 $mq_grouping = [];
1369 foreach ($media_query_matches as $media_query_match) {
1370 if (empty($media_query_match["CSS_MEDIA_QUERY_TYPE"]) === false) {
1371 $media_query_feature = "type";
1372 $media_query_value = strtolower($media_query_match["CSS_MEDIA_QUERY_TYPE"]);
1373 $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]);
1374 } elseif (empty($media_query_match["CSS_MEDIA_QUERY_FEATURE"]) === false) {
1375 $media_query_feature = strtolower($media_query_match["CSS_MEDIA_QUERY_FEATURE"]);
1376 $media_query_value = (array_key_exists("CSS_MEDIA_QUERY_CONDITION", $media_query_match) ? strtolower($media_query_match["CSS_MEDIA_QUERY_CONDITION"]) : null);
1377 $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]);
1378 } else {
1379 // partial error handling implementation per https://www.w3.org/TR/css3-mediaqueries/#error-handling
1380 $media_query_feature = "type";
1381 $media_query_value = "all";
1382 $media_query_operator = "not";
1383 }
1384 $mq_grouping[] = [$media_query_feature, $media_query_value, $media_query_operator];
1385 }
1386 if (count($mq_grouping) > 0) {
1387 $parsed_media_queries[] = $mq_grouping;
1388 }
1389 }
1390 $sub_media_queries = [];
1391 if (count($media_queries) > 0) {
1392 foreach ($media_queries as $media_query) {
1393 foreach ($parsed_media_queries as $parsed_media_query) {
1394 $sub_media_queries[] = array_merge($media_query, $parsed_media_query);
1395 }
1396 }
1397 } else {
1398 $sub_media_queries = $parsed_media_queries;
1399 }
1400 $this->_parse_css($match["CSS_ATMEDIA_BODY"], $sub_media_queries);
1401 break;
1402
1403 case "page":
1404 //This handles @page to be applied to page oriented media
1405 //Note: This has a reduced syntax:
1406 //@page { margin:1cm; color:blue; }
1407 //Not a sequence of styles like a full.css, but only the properties
1408 //of a single style, which is applied to the very first "root" frame before
1409 //processing other styles of the frame.
1410 //Working properties:
1411 // margin (for margin around edge of paper)
1412 // font-family (default font of pages)
1413 // color (default text color of pages)
1414 //Non working properties:
1415 // border
1416 // padding
1417 // background-color
1418 //Todo:Reason is unknown
1419 //Other properties (like further font or border attributes) not tested.
1420 //If a border or background color around each paper sheet is desired,
1421 //assign it to the <body> tag, possibly only for the css of the correct media type.
1422
1423 // If the page has a name, skip the style.
1424 $page_selector = trim($match["CSS_ATPAGE_RULE"]);
1425
1426 $key = null;
1427 switch ($page_selector) {
1428 case "":
1429 $key = "base";
1430 break;
1431
1432 case ":left":
1433 case ":right":
1434 case ":odd":
1435 case ":even":
1436 /** @noinspection PhpMissingBreakStatementInspection */
1437 case ":first":
1438 $key = $page_selector;
1439 break;
1440
1441 default:
1442 break 2;
1443 }
1444
1445 // Store the style for later...
1446 if (empty($this->_page_styles[$key])) {
1447 $this->_page_styles[$key] = $this->_parse_properties($match["CSS_ATPAGE_BODY"]);
1448 } else {
1449 $this->_page_styles[$key]->merge($this->_parse_properties($match["CSS_ATPAGE_BODY"]));
1450 }
1451 break;
1452
1453 case "font-face":
1454 $this->_parse_font_face($match["CSS_ATFONT_BODY"]);
1455 break;
1456
1457 default:
1458 // ignore everything else
1459 break;
1460 }
1461
1462 continue;
1463 }
1464
1465 if ($match["CSS_RULESET"] !== "") {
1466 $this->_parse_sections($match["CSS_RULESET"], $media_queries);
1467 }
1468 }
1469 }
1470
1471 /**
1472 * Resolve the given `url()` declaration to an absolute URL.
1473 *
1474 * @param string|null $val The declaration to resolve in the context of the stylesheet.
1475 * @param bool|null $resolve_blobs Indicates whether or not to resolve blob URLs to their final value.
1476 * @return string The resolved URL, or `none`, if the value is `none`,
1477 * invalid, or points to a non-existent local file.
1478 */
1479 public function resolve_url($val, $resolve_blobs = false): string
1480 {
1481 $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
1482
1483 static $pattern = "/" . self::PATTERN_CSS_URL_FN . "/isx";
1484 if ($val === null || $val === "" || strcasecmp($val, "none") === 0) {
1485 $path = "none";
1486 } elseif (preg_match($pattern, $val, $matches)) {
1487 // Resolve the url now in the context of the current stylesheet
1488 $url = $matches["CSS_URL_FN_VALUE"];
1489 switch ($matches["CSS_URL_FN_QUOTE"]) {
1490 case "\"":
1491 $url = str_replace("\\\"", "\"", $url);
1492 break;
1493 case "'":
1494 $url = str_replace("\\'", "'", $url);
1495 break;
1496 default:
1497 $url = str_replace(["\\(", "\\)"], ["(", ")"], $url);
1498 break;
1499 }
1500 if ($resolve_blobs === true && strpos($url, "blob://") !== false) {
1501 $url = $this->_blobs[substr($url, 7)] ?? "";
1502 }
1503 $path = Helpers::build_url(
1504 $this->_protocol,
1505 $this->_base_host,
1506 $this->_base_path,
1507 $url,
1508 $this->_dompdf->getOptions()->getChroot()
1509 );
1510
1511 if ($path !== null && strpos($path, "blob:") !== 0) {
1512 [$protocol] = Helpers::explode_url($path);
1513 $options = $this->_dompdf->getOptions();
1514 $allowed_protocols = $options->getAllowedProtocols();
1515 if (!array_key_exists($protocol, $allowed_protocols)) {
1516 Helpers::record_warnings(E_USER_WARNING, "Permission denied on $path. The communication protocol is not supported.", __FILE__, __LINE__);
1517 $path = null;
1518 }
1519
1520 foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
1521 [$result, $message] = $rule($path);
1522 if ($result !== true) {
1523 Helpers::record_warnings(E_USER_WARNING, "Error loading $path: $message", __FILE__, __LINE__);
1524 $path = null;
1525 }
1526 }
1527 }
1528
1529 if ($path === null) {
1530 $path = "none";
1531 }
1532 } else {
1533 $path = "none";
1534 }
1535 if ($DEBUGCSS) {
1536 $parsed_url = Helpers::explode_url($path);
1537 print "<pre>[_image\n";
1538 print_r($parsed_url);
1539 print $this->_protocol . "\n" . $this->_base_path . "\n" . $path . "\n";
1540 print "_image]</pre>";
1541 }
1542 return $path;
1543 }
1544
1545 /**
1546 * parse @import at-rule
1547 *
1548 * @param string $url the url of the imported CSS file
1549 */
1550 private function _parse_import($url, $import_media_query)
1551 {
1552 // if URL is a CSS string, wrap it in the url function for parsing by the resolve_url method
1553 if (mb_strpos($url, "url(") === false) {
1554 $url = "url($url)";
1555 }
1556 if (($url = $this->resolve_url($url, true)) === "none") {
1557 return;
1558 }
1559
1560 // Store our current base url properties in case the new url is elsewhere
1561 $protocol = $this->_protocol;
1562 $host = $this->_base_host;
1563 $path = $this->_base_path;
1564
1565 $media_query_regex = "/" . self::PATTERN_MEDIA_QUERY . "/isx";
1566 $media_queries = preg_split("/\s*(,|\Wor\W)\s*/", mb_strtolower(trim($import_media_query ?? ""), "UTF-8"));
1567 if (count($media_queries) === 0) {
1568 $this->load_css_file($url, $this->_current_origin);
1569 } else {
1570 // Set the page width, height, and orientation based on the canvas paper size
1571 $canvas = $this->_dompdf->getCanvas();
1572 $paper_width = $canvas->get_width();
1573 $paper_height = $canvas->get_height();
1574 $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
1575
1576 $style = $this->_page_styles["base"] ?? new Style($this);
1577 if (is_array($style->size)) {
1578 $paper_width = $style->size[0];
1579 $paper_height = $style->size[1];
1580 $paper_orientation = ($paper_width > $paper_height ? "landscape" : "portrait");
1581 }
1582
1583 $acceptedmedia = self::$ACCEPTED_GENERIC_MEDIA_TYPES;
1584 $acceptedmedia[] = $this->_dompdf->getOptions()->getDefaultMediaType();
1585
1586 foreach ($media_queries as $media_query) {
1587 $media_query_matches = [];
1588 if (preg_match_all($media_query_regex, $media_query, $media_query_matches, PREG_SET_ORDER) === false) {
1589 continue;
1590 }
1591
1592 foreach ($media_query_matches as $media_query_match) {
1593 if (empty($media_query_match["CSS_MEDIA_QUERY_TYPE"]) === false) {
1594 $media_query_feature = "type";
1595 $media_query_value = strtolower($media_query_match["CSS_MEDIA_QUERY_TYPE"]);
1596 $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]);
1597 } elseif (empty($media_query_match["CSS_MEDIA_QUERY_FEATURE"]) === false) {
1598 $media_query_feature = strtolower($media_query_match["CSS_MEDIA_QUERY_FEATURE"]);
1599 $media_query_value = (array_key_exists("CSS_MEDIA_QUERY_CONDITION", $media_query_match) ? strtolower($media_query_match["CSS_MEDIA_QUERY_CONDITION"]) : null);
1600 $media_query_operator = strtolower($media_query_match["CSS_MEDIA_QUERY_OP"]);
1601 } else {
1602 // partial error handling implementation per https://www.w3.org/TR/css3-mediaqueries/#error-handling
1603 $media_query_feature = "type";
1604 $media_query_value = "all";
1605 $media_query_operator = "not";
1606 }
1607
1608 switch ($media_query_feature) {
1609 case "height":
1610 $feature_match = $paper_height === (float)$style->length_in_pt($media_query_value);
1611 break;
1612 case "min-height":
1613 $feature_match = $paper_height >= (float)$style->length_in_pt($media_query_value);
1614 break;
1615 case "max-height":
1616 $feature_match = $paper_height <= (float)$style->length_in_pt($media_query_value);
1617 break;
1618 case "width":
1619 $feature_match = $paper_width === (float)$style->length_in_pt($media_query_value);
1620 break;
1621 case "min-width":
1622 $feature_match = $paper_width >= (float)$style->length_in_pt($media_query_value);
1623 break;
1624 case "max-width":
1625 $feature_match = $paper_width <= (float)$style->length_in_pt($media_query_value);
1626 break;
1627 case "orientation":
1628 $feature_match = $paper_orientation === $media_query_value;
1629 break;
1630 case "type":
1631 $feature_match = in_array($media_query_value, $acceptedmedia, true);
1632 break;
1633 default:
1634 Helpers::record_warnings(E_USER_WARNING, "Unknown media query: $media_query_feature", __FILE__, __LINE__);
1635 continue (2);
1636 }
1637 $negate = $media_query_operator === "not";
1638 if ($negate xor !$feature_match) {
1639 continue (2);
1640 }
1641 }
1642
1643 //TODO: pass media queries as an argument to load_css_file and apply to all contained styles
1644 // to better accommodate styling content in, for example, documents with varying page orientations
1645 $this->load_css_file($url, $this->_current_origin);
1646 break; // stop here so we don't load the same CSS more than once (at least until we implement that TODO)
1647 }
1648 }
1649
1650 // Restore the current base url
1651 $this->_protocol = $protocol;
1652 $this->_base_host = $host;
1653 $this->_base_path = $path;
1654 }
1655
1656 /**
1657 * parse @font-face{} sections
1658 * http://www.w3.org/TR/css3-fonts/#the-font-face-rule
1659 *
1660 * @param string $str CSS @font-face rules
1661 */
1662 private function _parse_font_face($str)
1663 {
1664 $descriptors = $this->_parse_properties($str);
1665
1666 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);
1667
1668 $valid_sources = [];
1669 foreach ($sources as $source) {
1670 $urlfn = $source["CSS_URL_FN"] ?? "";
1671 $format = strtolower($source["CSS_STRING_VALUE"] ?? $source["FORMAT_VALUE"] ?? "truetype");
1672
1673 if ($urlfn !== "" && $format === "truetype") {
1674 $url = $this->resolve_url($urlfn, true);
1675 if ($url === "none") {
1676 continue;
1677 }
1678 $source_info = [
1679 "uri" => $urlfn,
1680 "format" => $format,
1681 "path" => $url,
1682 ];
1683 $valid_sources[] = $source_info;
1684 }
1685 }
1686
1687 if (empty($valid_sources)) {
1688 return;
1689 }
1690
1691 $style = [
1692 "family" => $descriptors->get_font_family_raw(),
1693 "weight" => $descriptors->font_weight,
1694 "style" => $descriptors->font_style,
1695 ];
1696
1697 foreach ($valid_sources as $valid_source) {
1698 if ($this->fontMetrics->registerFont($style, $valid_source["path"], $this->_dompdf->getHttpContext())) {
1699 break;
1700 }
1701 }
1702 }
1703
1704 /**
1705 * parse regular CSS blocks
1706 *
1707 * _parse_properties() creates a new Style object based on the provided
1708 * CSS rules.
1709 *
1710 * @param string $str CSS rules
1711 * @return Style
1712 */
1713 private function _parse_properties($str)
1714 {
1715 $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
1716
1717 if ($DEBUGCSS) {
1718 print '[_parse_properties';
1719 }
1720
1721 $delims = ['"' => '"', "'" => "'", '(' => ')'];
1722 $delim = null;
1723 $len = strlen($str);
1724 $properties = [];
1725 $char = null;
1726 $prev = null;
1727 for ($pos = 0; $pos < $len; $pos++) {
1728 $prev = $char;
1729 $char = $str[$pos];
1730 if ($delim !== null) {
1731 if ($char === $delims[$delim] && $prev !== '\\') {
1732 $delim = null;
1733 }
1734 continue;
1735 }
1736 if (isset($delims[$char]) && $prev !== '\\') {
1737 $delim = $char;
1738 continue;
1739 }
1740 if ($char === ';' && $prev !== '\\') {
1741 $properties[] = substr($str, 0, $pos);
1742 $str = substr($str, $pos+1);
1743 $pos = 0;
1744 $len = strlen($str);
1745 }
1746 }
1747 $properties[] = $str;
1748 $style = new Style($this, Stylesheet::ORIG_AUTHOR);
1749 foreach ($properties as $prop) {
1750 $prop = trim($prop);
1751 if ($prop === "") {
1752 continue;
1753 }
1754
1755 $important = false;
1756 if (substr($prop, -9) === 'important') {
1757 $prop_tmp = rtrim(substr($prop, 0, -9));
1758
1759 if (substr($prop_tmp, -1) === '!') {
1760 $prop = rtrim(substr($prop_tmp, 0, -1));
1761 $important = true;
1762 }
1763 }
1764
1765 $i = strpos($prop, ":");
1766 if ($i === false) {
1767 if ($DEBUGCSS) {
1768 print "(novalue $prop)";
1769 }
1770 continue;
1771 }
1772
1773 $prop_name = rtrim(substr($prop, 0, $i));
1774 $value = ltrim(substr($prop, $i + 1));
1775
1776 // Regular (non-custom) properties are case-insensitive
1777 if (strncmp($prop_name, "--", 2) !== 0) {
1778 $prop_name = strtolower($prop_name);
1779 }
1780
1781 if ($DEBUGCSS) {
1782 print "($prop_name:=$value" . ($important ? " !IMPORTANT" : "") . ")";
1783 }
1784
1785 $style->set_prop($prop_name, $value, $important, false);
1786 }
1787
1788 if ($DEBUGCSS) {
1789 print '_parse_properties]';
1790 }
1791
1792 return $style;
1793 }
1794
1795 /**
1796 * parse selector + rulesets
1797 *
1798 * @param string $str CSS selectors and rulesets
1799 * @param array $media_queries
1800 */
1801 private function _parse_sections($str, $media_queries = [])
1802 {
1803 // Pre-process selectors: collapse all whitespace and strip whitespace
1804 // around '>', '.', ':', '+', '~', '#'
1805 $patterns = ["/\s+/", "/\s+([>.:+~#])\s+/"];
1806 $replacements = [" ", "\\1"];
1807 $DEBUGCSS = $this->_dompdf->getOptions()->getDebugCss();
1808
1809 $sections = explode("}", $str);
1810 if ($DEBUGCSS) print '[_parse_sections';
1811 foreach ($sections as $sect) {
1812 $i = mb_strpos($sect, "{", 0, "UTF-8");
1813 if ($i === false) { continue; }
1814
1815 if ($DEBUGCSS) print '[section';
1816
1817 $selector_str = preg_replace($patterns, $replacements, mb_substr($sect, 0, $i, "UTF-8"));
1818 $selectors = preg_split("/,(?![^\(]*\))/", $selector_str, 0, PREG_SPLIT_NO_EMPTY);
1819 $style = $this->_parse_properties(trim(mb_substr($sect, $i + 1, null, "UTF-8")));
1820
1821 // Assign it to the selected elements
1822 foreach ($selectors as $selector) {
1823 $selector = trim($selector);
1824
1825 if ($selector === "") {
1826 if ($DEBUGCSS) print '#empty#';
1827 continue;
1828 }
1829 if ($DEBUGCSS) print '#' . $selector . '#';
1830 //if ($DEBUGCSS) { if (strpos($selector,'p') !== false) print '!!!p!!!#'; }
1831
1832 //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
1833 if (count($media_queries) > 0) {
1834 $style->set_media_queries($media_queries);
1835 }
1836 $this->add_style($selector, $style);
1837 }
1838
1839 if ($DEBUGCSS) {
1840 print 'section]';
1841 }
1842 }
1843
1844 if ($DEBUGCSS) {
1845 print "_parse_sections]\n";
1846 }
1847 }
1848
1849 /**
1850 * Parses a CSS string containing quotes and escaped hex characters.
1851 * https://www.w3.org/TR/CSS21/syndata.html#characters
1852 *
1853 * @param string $string The string to parse.
1854 *
1855 * @return string
1856 */
1857 public function parse_string(string $string): string
1858 {
1859 // Strip string quotes and escapes
1860 $string = preg_replace('/^["\']|["\']$/', "", $string);
1861 $string = preg_replace("/\\\\([^0-9a-fA-F])/", "\\1", $string);
1862
1863 // Convert escaped hex characters (e.g. \A => newline)
1864 return preg_replace_callback(
1865 "/\\\\([0-9a-fA-F]{1,6})\s?/",
1866 function ($matches) { return Helpers::unichr(hexdec($matches[1])); },
1867 $string
1868 ) ?? "";
1869 }
1870
1871 /**
1872 * @return string
1873 */
1874 public function getDefaultStylesheet()
1875 {
1876 $options = $this->_dompdf->getOptions();
1877 $rootDir = realpath($options->getRootDir());
1878 return Helpers::build_url("file://", "", $rootDir, $rootDir . self::DEFAULT_STYLESHEET, $options->getChroot());
1879 }
1880
1881 /**
1882 * @param FontMetrics $fontMetrics
1883 * @return $this
1884 */
1885 public function setFontMetrics(FontMetrics $fontMetrics)
1886 {
1887 $this->fontMetrics = $fontMetrics;
1888 return $this;
1889 }
1890
1891 /**
1892 * @return FontMetrics
1893 */
1894 public function getFontMetrics()
1895 {
1896 return $this->fontMetrics;
1897 }
1898
1899 /**
1900 * dumps the entire stylesheet as a string
1901 *
1902 * Generates a string of each selector and associated style in the
1903 * Stylesheet. Useful for debugging.
1904 *
1905 * @return string
1906 */
1907 function __toString()
1908 {
1909 $str = "";
1910 foreach ($this->_styles as $selector => $selector_styles) {
1911 foreach ($selector_styles as $style) {
1912 $str .= "$selector => " . $style->__toString() . "\n";
1913 }
1914 }
1915
1916 return $str;
1917 }
1918 }
1919