PluginProbe
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance / trunk
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance vtrunk
4.7.0 4.6.1 4.6.0 4.5.5 4.5.4 4.5.3 4.5.2 3.2.20 3.2.21 3.2.22 3.2.3 3.2.5 3.2.6 3.2.7 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.7.0 3.7.1 All 111 releases
wp-optimize / vendor / simplehtmldom / simplehtmldom / HtmlNode.php

HtmlNode.php in WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance trunk, at vendor/simplehtmldom/simplehtmldom/HtmlNode.php

1,462 lines 34.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php namespace simplehtmldom;
2
3 /**
4 * Website: http://sourceforge.net/projects/simplehtmldom/
5 * Acknowledge: Jose Solorzano (https://sourceforge.net/projects/php-html/)
6 *
7 * Licensed under The MIT License
8 * See the LICENSE file in the project root for more information.
9 *
10 * Authors:
11 * S.C. Chen
12 * John Schlick
13 * Rus Carroll
14 * logmanoriginal
15 *
16 * Contributors:
17 * Yousuke Kumakura
18 * Vadim Voituk
19 * Antcs
20 *
21 * Version $Rev$
22 */
23
24 include_once 'constants.php';
25 include_once 'Debug.php';
26
27 class HtmlNode
28 {
29 const HDOM_TYPE_ELEMENT = 1;
30 const HDOM_TYPE_COMMENT = 2;
31 const HDOM_TYPE_TEXT = 3;
32 const HDOM_TYPE_ROOT = 5;
33 const HDOM_TYPE_UNKNOWN = 6;
34 const HDOM_TYPE_CDATA = 7;
35
36 const HDOM_QUOTE_DOUBLE = 0;
37 const HDOM_QUOTE_SINGLE = 1;
38 const HDOM_QUOTE_NO = 3;
39
40 const HDOM_INFO_BEGIN = 0;
41 const HDOM_INFO_END = 1;
42 const HDOM_INFO_QUOTE = 2;
43 const HDOM_INFO_SPACE = 3;
44 const HDOM_INFO_TEXT = 4;
45 const HDOM_INFO_INNER = 5;
46 const HDOM_INFO_OUTER = 6;
47 const HDOM_INFO_ENDSPACE = 7;
48
49 public $nodetype = self::HDOM_TYPE_TEXT;
50 public $tag = 'text';
51 public $attr = array();
52 public $children = array();
53 public $nodes = array();
54 public $parent = null;
55 public $_ = array();
56 private $dom = null;
57
58 function __call($func, $args)
59 {
60 // Allow users to call methods with lower_case syntax
61 switch($func)
62 {
63 case 'children':
64 $actual_function = 'childNodes'; break;
65 case 'first_child':
66 $actual_function = 'firstChild'; break;
67 case 'has_child':
68 $actual_function = 'hasChildNodes'; break;
69 case 'last_child':
70 $actual_function = 'lastChild'; break;
71 case 'next_sibling':
72 $actual_function = 'nextSibling'; break;
73 case 'prev_sibling':
74 $actual_function = 'previousSibling'; break;
75 default:
76 trigger_error(
77 'Call to undefined method ' . __CLASS__ . '::' . $func . '()',
78 E_USER_ERROR
79 );
80 }
81
82 // phpcs:ignore Generic.Files.LineLength
83 Debug::log(__CLASS__ . '->' . $func . '() has been deprecated and will be removed in the next major version of simplehtmldom. Use ' . __CLASS__ . '->' . $actual_function . '() instead.');
84
85 return call_user_func_array(array($this, $actual_function), $args);
86 }
87
88 function __construct($dom)
89 {
90 if ($dom instanceof HtmlDocument)
91 {
92 $this->dom = $dom;
93 $dom->nodes[] = $this;
94 }
95 }
96
97 function __debugInfo()
98 {
99 // Translate node type to human-readable form
100 switch($this->nodetype)
101 {
102 case self::HDOM_TYPE_ELEMENT:
103 $nodetype = "HDOM_TYPE_ELEMENT ($this->nodetype)";
104 break;
105 case self::HDOM_TYPE_COMMENT:
106 $nodetype = "HDOM_TYPE_COMMENT ($this->nodetype)";
107 break;
108 case self::HDOM_TYPE_TEXT:
109 $nodetype = "HDOM_TYPE_TEXT ($this->nodetype)";
110 break;
111 case self::HDOM_TYPE_ROOT:
112 $nodetype = "HDOM_TYPE_ROOT ($this->nodetype)";
113 break;
114 case self::HDOM_TYPE_CDATA:
115 $nodetype = "HDOM_TYPE_CDATA ($this->nodetype)";
116 break;
117 case self::HDOM_TYPE_UNKNOWN:
118 default:
119 $nodetype = "HDOM_TYPE_UNKNOWN ($this->nodetype)";
120 }
121
122 return array(
123 'nodetype' => $nodetype,
124 'tag' => $this->tag,
125 'attributes' => empty($this->attr) ? 'none' : $this->attr,
126 'nodes' => empty($this->nodes) ? 'none' : $this->nodes
127 );
128 }
129
130 function __toString()
131 {
132 return $this->outertext();
133 }
134
135 function clear()
136 {
137 unset($this->dom); // Break link to origin
138 unset($this->parent); // Break link to branch
139 }
140
141 /** @codeCoverageIgnore */
142 function dump($show_attr = true, $depth = 0)
143 {
144 echo str_repeat("\t", $depth) . $this->tag;
145
146 if ($show_attr && count($this->attr) > 0) {
147 echo '(';
148 foreach ($this->attr as $k => $v) {
149 echo "[$k]=>\"$v\", ";
150 }
151 echo ')';
152 }
153
154 echo "\n";
155
156 if ($this->nodes) {
157 foreach ($this->nodes as $node) {
158 $node->dump($show_attr, $depth + 1);
159 }
160 }
161 }
162
163 /** @codeCoverageIgnore */
164 function dump_node($echo = true)
165 {
166 $string = $this->tag;
167
168 if (count($this->attr) > 0) {
169 $string .= '(';
170 foreach ($this->attr as $k => $v) {
171 $string .= "[$k]=>\"$v\", ";
172 }
173 $string .= ')';
174 }
175
176 if (count($this->_) > 0) {
177 $string .= ' $_ (';
178 foreach ($this->_ as $k => $v) {
179 if (is_array($v)) {
180 $string .= "[$k]=>(";
181 foreach ($v as $k2 => $v2) {
182 $string .= "[$k2]=>\"$v2\", ";
183 }
184 $string .= ')';
185 } else {
186 $string .= "[$k]=>\"$v\", ";
187 }
188 }
189 $string .= ')';
190 }
191
192 if (isset($this->text)) {
193 $string .= " text: ($this->text)";
194 }
195
196 $string .= ' HDOM_INNER_INFO: ';
197
198 if (isset($node->_[self::HDOM_INFO_INNER])) {
199 $string .= "'" . $node->_[self::HDOM_INFO_INNER] . "'";
200 } else {
201 $string .= ' NULL ';
202 }
203
204 $string .= ' children: ' . count($this->children);
205 $string .= ' nodes: ' . count($this->nodes);
206 $string .= "\n";
207
208 if ($echo) {
209 echo $string;
210 return;
211 } else {
212 return $string;
213 }
214 }
215
216 function parent($parent = null)
217 {
218 // I am SURE that this doesn't work properly.
219 // It fails to unset the current node from its current parents nodes or
220 // children list first.
221 if ($parent !== null) {
222 $this->parent = $parent;
223 $this->parent->nodes[] = $this;
224 $this->parent->children[] = $this;
225 }
226
227 return $this->parent;
228 }
229
230 function find_ancestor_tag($tag)
231 {
232 if ($this->parent === null) return null;
233
234 $ancestor = $this->parent;
235
236 while (!is_null($ancestor)) {
237 if ($ancestor->tag === $tag) {
238 break;
239 }
240
241 $ancestor = $ancestor->parent;
242 }
243
244 return $ancestor;
245 }
246
247 function innertext()
248 {
249 if (isset($this->_[self::HDOM_INFO_INNER])) {
250 $ret = $this->_[self::HDOM_INFO_INNER];
251 } elseif (isset($this->_[self::HDOM_INFO_TEXT])) {
252 $ret = $this->_[self::HDOM_INFO_TEXT];
253 } else {
254 $ret = '';
255 }
256
257 foreach ($this->nodes as $n) {
258 $ret .= $n->outertext();
259 }
260
261 return $this->convert_text($ret);
262 }
263
264 function outertext()
265 {
266 if ($this->tag === 'root') {
267 return $this->innertext();
268 }
269
270 // todo: What is the use of this callback? Remove?
271 if ($this->dom && $this->dom->callback !== null) {
272 call_user_func_array($this->dom->callback, array($this));
273 }
274
275 if (isset($this->_[self::HDOM_INFO_OUTER])) {
276 return $this->convert_text($this->_[self::HDOM_INFO_OUTER]);
277 }
278
279 if (isset($this->_[self::HDOM_INFO_TEXT])) {
280 return $this->convert_text($this->_[self::HDOM_INFO_TEXT]);
281 }
282
283 $ret = '';
284
285 if (isset($this->_[self::HDOM_INFO_BEGIN])) {
286 $ret = $this->makeup();
287 }
288
289 if (isset($this->_[self::HDOM_INFO_INNER]) && $this->tag !== HtmlElement::BR) {
290 if (HtmlElement::isRawTextElement($this->tag)){
291 $ret .= $this->_[self::HDOM_INFO_INNER];
292 } else {
293 if ($this->dom && $this->dom->targetCharset) {
294 $charset = $this->dom->targetCharset;
295 } else {
296 $charset = DEFAULT_TARGET_CHARSET;
297 }
298 if ($this->dom->enable_htmlentity_operations) {
299 $ret .= htmlentities($this->_[self::HDOM_INFO_INNER], ENT_QUOTES | ENT_SUBSTITUTE, $charset);
300 } else {
301 $ret .= $this->_[self::HDOM_INFO_INNER];
302 }
303 }
304 }
305
306 if ($this->nodes) {
307 foreach ($this->nodes as $n) {
308 $ret .= $n->outertext();
309 }
310 }
311
312 if (isset($this->_[self::HDOM_INFO_END]) && $this->_[self::HDOM_INFO_END] != 0) {
313 $ret .= '</' . $this->tag . '>';
314 }
315
316 return $this->convert_text($ret);
317 }
318
319 /**
320 * Returns true if the provided element is a block level element
321 * @link https://www.w3resource.com/html/HTML-block-level-and-inline-elements.php
322 */
323 protected function is_block_element($node)
324 {
325 return HtmlElement::isPalpableContent($node->tag) &&
326 !HtmlElement::isMetadataContent($node->tag) &&
327 !HtmlElement::isPhrasingContent($node->tag) &&
328 !HtmlElement::isEmbeddedContent($node->tag) &&
329 !HtmlElement::isInteractiveContent($node->tag);
330 }
331
332 function text($trim = true)
333 {
334 if (HtmlElement::isRawTextElement($this->tag)) {
335 return '';
336 }
337
338 $ret = '';
339
340 switch ($this->nodetype) {
341 case self::HDOM_TYPE_COMMENT:
342 case self::HDOM_TYPE_UNKNOWN:
343 return '';
344 case self::HDOM_TYPE_TEXT:
345 $ret = $this->_[self::HDOM_INFO_TEXT];
346 break;
347 default:
348 if (isset($this->_[self::HDOM_INFO_INNER])) {
349 $ret = $this->_[self::HDOM_INFO_INNER];
350 }
351 break;
352 }
353
354 // Replace and collapse whitespace
355 $ret = preg_replace('/\s+/u', ' ', $ret);
356
357 // Reduce whitespace at start/end to a single (or none) space
358 $ret = preg_replace('/[ \t\n\r\0\x0B\xC2\xA0]+$/u', $trim ? '' : ' ', $ret);
359 $ret = preg_replace('/^[ \t\n\r\0\x0B\xC2\xA0]+/u', $trim ? '' : ' ', $ret);
360
361 // TODO: Remove BR_TEXT customization.
362 // It has no practical use and only makes the code harder to read.
363 if ($this->dom){ // for the root node, ->dom is undefined.
364 $br_text = $this->dom->default_br_text ?: DEFAULT_BR_TEXT;
365 }
366
367 foreach ($this->nodes as $n) {
368
369 if ($this->is_block_element($n)) {
370 $block = $this->convert_text($n->text($trim));
371
372 if ($block === '') {
373 $ret = rtrim($ret) . "\n\n";
374 continue;
375 }
376
377 if ($ret === ''){
378 $ret = $block . "\n\n";
379 continue;
380 }
381
382 $ret = rtrim($ret) . "\n\n" . $block . "\n\n";
383 continue;
384 }
385
386 if (strtolower($n->tag) === HtmlElement::BR) {
387
388 if ($ret === ''){
389 // Don't start with a line break.
390 continue;
391 }
392
393 $ret .= $br_text;
394 continue;
395 }
396
397 $text = $this->convert_text($n->text($trim));
398
399 if ($text === ''){
400 continue;
401 }
402
403 if ($ret === ''){
404 $ret = ltrim($text);
405 continue;
406 }
407
408 if (substr($ret, -1) === "\n" ||
409 substr($ret, -1) === ' ' ||
410 substr($ret, -strlen($br_text)) === $br_text){
411 $ret .= ltrim($text);
412 continue;
413 }
414
415 $ret .= ' ' . ltrim($text);
416 }
417
418 return trim($ret);
419 }
420
421 function xmltext()
422 {
423 $ret = $this->innertext();
424 $ret = str_ireplace('<![CDATA[', '', $ret);
425 $ret = str_replace(']]>', '', $ret);
426 return $ret;
427 }
428
429 function makeup()
430 {
431 // text, comment, unknown
432 if (isset($this->_[self::HDOM_INFO_TEXT])) {
433 return $this->_[self::HDOM_INFO_TEXT];
434 }
435
436 $ret = '<' . $this->tag;
437
438 foreach ($this->attr as $key => $val) {
439
440 // skip removed attribute
441 if ($val === null || $val === false) { continue; }
442
443 if (isset($this->_[self::HDOM_INFO_SPACE][$key])) {
444 $ret .= $this->_[self::HDOM_INFO_SPACE][$key][0];
445 } else {
446 $ret .= ' ';
447 }
448
449 //no value attr: nowrap, checked selected...
450 if ($val === true) {
451 $ret .= $key;
452 } else {
453 if (isset($this->_[self::HDOM_INFO_QUOTE][$key])) {
454 $quote_type = $this->_[self::HDOM_INFO_QUOTE][$key];
455 } else {
456 $quote_type = self::HDOM_QUOTE_DOUBLE;
457 }
458
459 switch ($quote_type)
460 {
461 case self::HDOM_QUOTE_SINGLE:
462 $quote = '\'';
463 break;
464 case self::HDOM_QUOTE_NO:
465 if (strpos($val, ' ') !== false ||
466 strpos($val, "\t") !== false ||
467 strpos($val, "\f") !== false ||
468 strpos($val, "\r") !== false ||
469 strpos($val, "\n") !== false) {
470 $quote = '"';
471 } else {
472 $quote = '';
473 }
474 break;
475 case self::HDOM_QUOTE_DOUBLE:
476 default:
477 $quote = '"';
478 }
479
480 if ($this->dom->enable_htmlentity_operations) {
481 $val = htmlentities($val, ENT_COMPAT, $this->dom->target_charset);
482 }
483
484 $ret .= $key
485 . (isset($this->_[self::HDOM_INFO_SPACE][$key]) ? $this->_[self::HDOM_INFO_SPACE][$key][1] : '')
486 . '='
487 . (isset($this->_[self::HDOM_INFO_SPACE][$key]) ? $this->_[self::HDOM_INFO_SPACE][$key][2] : '')
488 . $quote
489 . $val
490 . $quote;
491 }
492 }
493
494 if(isset($this->_[self::HDOM_INFO_ENDSPACE])) {
495 $ret .= $this->_[self::HDOM_INFO_ENDSPACE];
496 }
497
498 return $ret . '>';
499 }
500
501 function find($selector, $idx = null, $lowercase = false)
502 {
503 $selectors = $this->parse_selector($selector);
504 if (($count = count($selectors)) === 0) { return array(); }
505 $found_keys = array();
506
507 for ($c = 0; $c < $count; ++$c) {
508 if (($level = count($selectors[$c])) === 0) {
509 Debug::log_once('Empty selector (' . $selector . ') matches nothing.');
510 return array();
511 }
512
513 if (!isset($this->_[self::HDOM_INFO_BEGIN])) {
514 Debug::log_once('Invalid operation. The current node has no start tag.');
515 return array();
516 }
517
518 $head = array($this->_[self::HDOM_INFO_BEGIN] => 1);
519 $cmd = ' '; // Combinator
520
521 // handle descendant selectors, no recursive!
522 for ($l = 0; $l < $level; ++$l) {
523 $ret = array();
524
525 foreach ($head as $k => $v) {
526 $n = ($k === -1) ? $this->dom->root : $this->dom->nodes[$k];
527 //PaperG - Pass this optional parameter on to the seek function.
528 $n->seek($selectors[$c][$l], $ret, $cmd, $lowercase);
529 }
530
531 $head = $ret;
532 $cmd = $selectors[$c][$l][6]; // Next Combinator
533 }
534
535 foreach ($head as $k => $v) {
536 if (!isset($found_keys[$k])) {
537 $found_keys[$k] = 1;
538 }
539 }
540 }
541
542 // sort keys
543 ksort($found_keys);
544
545 $found = array();
546 foreach ($found_keys as $k => $v) {
547 $found[] = $this->dom->nodes[$k];
548 }
549
550 // return nth-element or array
551 if (is_null($idx)) { return $found; }
552 elseif ($idx < 0) { $idx = count($found) + $idx; }
553 return (isset($found[$idx])) ? $found[$idx] : null;
554 }
555
556 function expect($selector, $idx = null, $lowercase = false)
557 {
558 return $this->find($selector, $idx, $lowercase) ?: null;
559 }
560
561 protected function seek($selector, &$ret, $parent_cmd, $lowercase = false)
562 {
563 list($ps_selector, $tag, $ps_element, $id, $class, $attributes, $cmb) = $selector;
564 $nodes = array();
565
566 if ($parent_cmd === ' ') { // Descendant Combinator
567 // Find parent closing tag if the current element doesn't have a closing
568 // tag (i.e. void element)
569 $end = (!empty($this->_[self::HDOM_INFO_END])) ? $this->_[self::HDOM_INFO_END] : 0;
570 if ($end == 0 && $this->parent) {
571 $parent = $this->parent;
572 while ($parent !== null && !isset($parent->_[self::HDOM_INFO_END])) {
573 $end -= 1;
574 $parent = $parent->parent;
575 }
576 $end += $parent->_[self::HDOM_INFO_END];
577 }
578
579 if ($end === 0) {
580 $end = count($this->dom->nodes);
581 }
582
583 // Get list of target nodes
584 $nodes_start = $this->_[self::HDOM_INFO_BEGIN] + 1;
585
586 // remove() makes $this->dom->nodes non-contiguous; use what is left.
587 $nodes = array_intersect_key(
588 $this->dom->nodes,
589 array_flip(range($nodes_start, $end))
590 );
591 } elseif ($parent_cmd === '>') { // Child Combinator
592 $nodes = $this->children;
593 } elseif ($parent_cmd === '+'
594 && $this->parent
595 && in_array($this, $this->parent->children)) { // Next-Sibling Combinator
596 $index = array_search($this, $this->parent->children, true) + 1;
597 if ($index < count($this->parent->children))
598 $nodes[] = $this->parent->children[$index];
599 } elseif ($parent_cmd === '~'
600 && $this->parent
601 && in_array($this, $this->parent->children)) { // Subsequent Sibling Combinator
602 $index = array_search($this, $this->parent->children, true);
603 $nodes = array_slice($this->parent->children, $index);
604 }
605
606 // Go through each element starting at this element until the end tag
607 // Note: If this element is a void tag, any previous void element is
608 // skipped.
609 foreach($nodes as $node) {
610
611 // Skip root nodes
612 if(!$node->parent) {
613 unset($node);
614 continue;
615 }
616
617 // Handle 'text' selector
618 if($tag === 'text') {
619
620 if($node->tag === 'text') {
621 $ret[array_search($node, $this->dom->nodes, true)] = 1;
622 }
623
624 if(isset($node->_[self::HDOM_INFO_INNER])) {
625 $ret[$node->_[self::HDOM_INFO_BEGIN]] = 1;
626 }
627
628 unset($node);
629 continue;
630
631 }
632
633 // Handle 'cdata' selector
634 if($tag === 'cdata') {
635
636 if($node->tag === 'cdata') {
637 $ret[$node->_[self::HDOM_INFO_BEGIN]] = 1;
638 }
639
640 unset($node);
641 continue;
642
643 }
644
645 // Handle 'comment'
646 if($tag === 'comment' && $node->tag === 'comment') {
647 $ret[$node->_[self::HDOM_INFO_BEGIN]] = 1;
648 unset($node);
649 continue;
650 }
651
652 // Skip if node isn't a child node (i.e. text nodes)
653 if(!in_array($node, $node->parent->children, true)) {
654 unset($node);
655 continue;
656 }
657
658 $pass = true;
659
660 // Skip if tag doesn't match
661 if ($tag !== '' && $tag !== $node->tag && $tag !== '*') {
662 $pass = false;
663 }
664
665 // Skip if ID doesn't exist
666 if ($pass && $id !== '' && !isset($node->attr['id'])) {
667 $pass = false;
668 }
669
670 // Check if ID matches
671 if ($pass && $id !== '' && isset($node->attr['id'])) {
672 // Note: Only consider the first ID (as browsers do)
673 $node_id = explode(' ', trim($node->attr['id']))[0];
674
675 if($id !== $node_id) { $pass = false; }
676 }
677
678 // Check if all class(es) exist
679 if ($pass && $class !== '' && is_array($class) && !empty($class)) {
680 if (isset($node->attr['class'])) {
681 // Apply the same rules for the pattern and attribute value
682 // Attribute values must not contain control characters other than space
683 // https://www.w3.org/TR/html/dom.html#text-content
684 // https://www.w3.org/TR/html/syntax.html#attribute-values
685 // https://www.w3.org/TR/xml/#AVNormalize
686 $node_classes = preg_replace("/[\r\n\t\s]+/u", ' ', $node->attr['class']);
687 $node_classes = trim($node_classes);
688 $node_classes = explode(' ', $node_classes);
689
690 if ($lowercase) {
691 $node_classes = array_map('strtolower', $node_classes);
692 }
693
694 foreach($class as $c) {
695 if(!in_array($c, $node_classes)) {
696 $pass = false;
697 break;
698 }
699 }
700 } else {
701 $pass = false;
702 }
703 }
704
705 // Check attributes
706 if ($pass
707 && $attributes !== ''
708 && is_array($attributes)
709 && !empty($attributes)) {
710 foreach($attributes as $a) {
711 list (
712 $att_name,
713 $att_expr,
714 $att_val,
715 $att_inv,
716 $att_case_sensitivity
717 ) = $a;
718
719 // Handle indexing attributes (i.e. "[2]")
720 /**
721 * Note: This is not supported by the CSS Standard but adds
722 * the ability to select items compatible to XPath (i.e.
723 * the 3rd element within it's parent).
724 *
725 * Note: This doesn't conflict with the CSS Standard which
726 * doesn't work on numeric attributes anyway.
727 */
728 if (is_numeric($att_name)
729 && $att_expr === ''
730 && $att_val === '') {
731 $count = 0;
732
733 // Find index of current element in parent
734 foreach ($node->parent->children as $c) {
735 if ($c->tag === $node->tag) ++$count;
736 if ($c === $node) break;
737 }
738
739 // If this is the correct node, continue with next
740 // attribute
741 if ($count === (int)$att_name) continue;
742 }
743
744 // Check attribute availability
745 if ($att_inv) { // Attribute should NOT be set
746 if (isset($node->attr[$att_name])) {
747 $pass = false;
748 break;
749 }
750 } else { // Attribute should be set
751 // todo: "plaintext" is not a valid CSS selector!
752 if ($att_name !== 'plaintext'
753 && !isset($node->attr[$att_name])) {
754 $pass = false;
755 break;
756 }
757 }
758
759 // Continue with next attribute if expression isn't defined
760 if ($att_expr === '') continue;
761
762 // If they have told us that this is a "plaintext"
763 // search then we want the plaintext of the node - right?
764 // todo "plaintext" is not a valid CSS selector!
765 if ($att_name === 'plaintext') {
766 $nodeKeyValue = $node->text();
767 } else {
768 $nodeKeyValue = $node->attr[$att_name];
769 }
770
771 // If lowercase is set, do a case-insensitive test of
772 // the value of the selector.
773 if ($lowercase) {
774 $check = $this->match(
775 $att_expr,
776 strtolower($att_val),
777 strtolower($nodeKeyValue),
778 $att_case_sensitivity
779 );
780 } else {
781 $check = $this->match(
782 $att_expr,
783 $att_val,
784 $nodeKeyValue,
785 $att_case_sensitivity
786 );
787 }
788
789 $check = $ps_element === 'not' ? !$check : $check;
790
791 if (!$check) {
792 $pass = false;
793 break;
794 }
795 }
796 }
797
798 // Found a match. Add to list and clear node
799 $pass = $ps_selector === 'not' ? !$pass : $pass;
800 if ($pass) $ret[$node->_[self::HDOM_INFO_BEGIN]] = 1;
801 unset($node);
802 }
803 }
804
805 protected function match($exp, $pattern, $value, $case_sensitivity)
806 {
807 if ($case_sensitivity === 'i') {
808 $pattern = strtolower($pattern);
809 $value = strtolower($value);
810 }
811
812 // Apply the same rules for the pattern and attribute value
813 // Attribute values must not contain control characters other than space
814 // https://www.w3.org/TR/html/dom.html#text-content
815 // https://www.w3.org/TR/html/syntax.html#attribute-values
816 // https://www.w3.org/TR/xml/#AVNormalize
817 $pattern = preg_replace("/[\r\n\t\s]+/u", ' ', $pattern);
818 $pattern = trim($pattern);
819
820 $value = preg_replace("/[\r\n\t\s]+/u", ' ', $value);
821 $value = trim($value);
822
823 switch ($exp) {
824 case '=':
825 return ($value === $pattern);
826 case '!=':
827 return ($value !== $pattern);
828 case '^=':
829 return preg_match('/^' . preg_quote($pattern, '/') . '/', $value);
830 case '$=':
831 return preg_match('/' . preg_quote($pattern, '/') . '$/', $value);
832 case '*=':
833 return preg_match('/' . preg_quote($pattern, '/') . '/', $value);
834 case '|=':
835 /**
836 * [att|=val]
837 *
838 * Represents an element with the att attribute, its value
839 * either being exactly "val" or beginning with "val"
840 * immediately followed by "-" (U+002D).
841 */
842 return strpos($value, $pattern) === 0;
843 case '~=':
844 /**
845 * [att~=val]
846 *
847 * Represents an element with the att attribute whose value is a
848 * whitespace-separated list of words, one of which is exactly
849 * "val". If "val" contains whitespace, it will never represent
850 * anything (since the words are separated by spaces). Also, if
851 * "val" is the empty string, it will never represent anything.
852 */
853 return in_array($pattern, explode(' ', trim($value)), true);
854 }
855
856 Debug::log('Unhandled attribute selector: ' . $exp . '!');
857 return false;
858 }
859
860 protected function parse_selector($selector_string)
861 {
862 /**
863 * Pattern of CSS selectors, modified from mootools (https://mootools.net/)
864 *
865 * Paperg: Add the colon to the attribute, so that it properly finds
866 * <tag attr:ibute="something" > like google does.
867 *
868 * Note: if you try to look at this attribute, you MUST use getAttribute
869 * since $dom->x:y will fail the php syntax check.
870 *
871 * Notice the \[ starting the attribute? and the @? following? This
872 * implies that an attribute can begin with an @ sign that is not
873 * captured. This implies that a html attribute specifier may start
874 * with an @ sign that is NOT captured by the expression. Farther study
875 * is required to determine of this should be documented or removed.
876 *
877 * Matches selectors in this order:
878 *
879 * [0] - full match
880 *
881 * [1] - pseudo selector
882 * Matches the pseudo selector (optional)
883 *
884 * [2] - tag name
885 * Matches the tag name consisting of zero or more words, colons,
886 * asterisks and hyphens.
887 *
888 * [3] - pseudo selector
889 * Matches the pseudo selector (optional)
890 *
891 * [4] - id name
892 * Optionally matches an id name, consisting of an "#" followed by
893 * the id name (one or more words and hyphens).
894 *
895 * [5] - class names (including dots)
896 * Optionally matches a list of classs, consisting of an "."
897 * followed by the class name (one or more words and hyphens)
898 * where multiple classes can be chained (i.e. ".foo.bar.baz")
899 *
900 * [6] - attributes
901 * Optionally matches the attributes list
902 *
903 * [7] - separator
904 * Matches the selector list separator
905 */
906 // phpcs:ignore Generic.Files.LineLength
907 $pattern = "/(?::(\w+)\()?([\w:*-]*)(?::(\w+)\()?(?:#([\w-]+))?(?:|\.([\w.-]+))?((?:\[@?!?[\w:-]+(?:[!*^$|~]?=(?![\"']).*?(?![\"'])|[!*^$|~]?=[\"'].*?[\"'])?(?:\s*?[iIsS]?)?])+)?\)?\)?([\/, >+~]+)/is";
908
909 preg_match_all(
910 $pattern,
911 trim($selector_string) . ' ', // Add final ' ' as pseudo separator
912 $matches,
913 PREG_SET_ORDER
914 );
915
916 $selectors = array();
917 $result = array();
918
919 foreach ($matches as $m) {
920 $m[0] = trim($m[0]);
921
922 // Skip NoOps
923 if ($m[0] === '' || $m[0] === '/' || $m[0] === '//') { continue; }
924
925 array_shift($m);
926
927 // Convert to lowercase
928 if ($this->dom->lowercase) {
929 $m[1] = strtolower($m[1]);
930 }
931
932 // Extract classes
933 if ($m[4] !== '') { $m[4] = explode('.', $m[4]); }
934
935 /* Extract attributes (pattern based on the pattern above!)
936
937 * [0] - full match
938 * [1] - attribute name
939 * [2] - attribute expression
940 * [3] - attribute value (with quotes)
941 * [4] - case sensitivity
942 *
943 * Note:
944 * Attributes can be negated with a "!" prefix to their name
945 * Attribute values may contain closing brackets "]"
946 */
947 if($m[5] !== '') {
948 preg_match_all(
949 "/\[@?(!?[\w:-]+)(?:([!*^$|~]?=)((?![\"']).*?(?![\"'])|[\"'].*?[\"']))?(?:\s+?([iIsS])?)?]/is",
950 trim($m[5]),
951 $attributes,
952 PREG_SET_ORDER
953 );
954
955 // Replace element by array
956 $m[5] = array();
957
958 foreach($attributes as $att) {
959 // Skip empty matches
960 if(trim($att[0]) === '') { continue; }
961
962 // Remove quotes from value
963 if (isset($att[3]) && $att[3] !== '' && ($att[3][0] === '"' || $att[3][0] === "'")) {
964 $att[3] = substr($att[3], 1, strlen($att[3]) - 2);
965 }
966
967 $inverted = (isset($att[1][0]) && $att[1][0] === '!');
968 $m[5][] = array(
969 $inverted ? substr($att[1], 1) : $att[1], // Name
970 (isset($att[2])) ? $att[2] : '', // Expression
971 (isset($att[3])) ? $att[3] : '', // Value
972 $inverted, // Inverted Flag
973 (isset($att[4])) ? strtolower($att[4]) : '', // Case-Sensitivity
974 );
975 }
976 }
977
978 // Sanitize Separator
979 if ($m[6] !== '' && trim($m[6]) === '') { // Descendant Separator
980 $m[6] = ' ';
981 } else { // Other Separator
982 $m[6] = trim($m[6]);
983 }
984
985 // Clear Separator if it's a Selector List
986 if ($is_list = ($m[6] === ',')) { $m[6] = ''; }
987
988 $result[] = $m;
989
990 if ($is_list) { // Selector List
991 $selectors[] = $result;
992 $result = array();
993 }
994 }
995
996 if (count($result) > 0) { $selectors[] = $result; }
997 return $selectors;
998 }
999
1000 function __get($name)
1001 {
1002 if (isset($this->attr[$name])) {
1003 return $this->convert_text($this->attr[$name]);
1004 }
1005
1006 switch ($name) {
1007 case 'outertext': return $this->outertext();
1008 case 'innertext': return $this->innertext();
1009 case 'plaintext': return $this->text();
1010 case 'xmltext': return $this->xmltext();
1011 }
1012
1013 return false;
1014 }
1015
1016 function __set($name, $value)
1017 {
1018 switch ($name) {
1019 case 'outertext':
1020 $this->_[self::HDOM_INFO_OUTER] = $value;
1021 break;
1022 case 'innertext':
1023 if (isset($this->_[self::HDOM_INFO_TEXT])) {
1024 $this->_[self::HDOM_INFO_TEXT] = '';
1025 }
1026 $this->_[self::HDOM_INFO_INNER] = $value;
1027 break;
1028 default: $this->attr[$name] = $value;
1029 }
1030 }
1031
1032 function __isset($name)
1033 {
1034 switch ($name) {
1035 case 'innertext':
1036 case 'plaintext':
1037 case 'outertext': return true;
1038 }
1039
1040 return isset($this->attr[$name]);
1041 }
1042
1043 function __unset($name)
1044 {
1045 if (isset($this->attr[$name])) { unset($this->attr[$name]); }
1046 }
1047
1048 function convert_text($text)
1049 {
1050 $converted_text = $text;
1051
1052 $sourceCharset = '';
1053 $targetCharset = '';
1054
1055 if ($this->dom) {
1056 $sourceCharset = strtoupper($this->dom->_charset);
1057 $targetCharset = strtoupper($this->dom->_target_charset);
1058 }
1059
1060 if ($sourceCharset !== '' &&
1061 $targetCharset !== '' &&
1062 $sourceCharset !== $targetCharset &&
1063 !($targetCharset === 'UTF-8' && self::is_utf8($text))) {
1064 $converted_text = iconv($sourceCharset, $targetCharset, $text);
1065 }
1066
1067 // Let's make sure that we don't have that silly BOM issue with any of the utf-8 text we output.
1068 if ($targetCharset === 'UTF-8') {
1069 if (substr($converted_text, 0, 3) === "\xef\xbb\xbf") {
1070 $converted_text = substr($converted_text, 3);
1071 }
1072 }
1073
1074 return $converted_text;
1075 }
1076
1077 static function is_utf8($str)
1078 {
1079 if (extension_loaded('mbstring')){
1080 return mb_detect_encoding($str, ['UTF-8'], true) === 'UTF-8';
1081 }
1082
1083 // This code was copied from https://www.php.net/manual/en/function.mb-detect-encoding.php#85294
1084 $c = 0; $b = 0;
1085 $bits = 0;
1086 $len = strlen($str);
1087 for($i = 0; $i < $len; $i++) {
1088 $c = ord($str[$i]);
1089 if($c > 128) {
1090 if(($c >= 254)) { return false; }
1091 elseif($c >= 252) { $bits = 6; }
1092 elseif($c >= 248) { $bits = 5; }
1093 elseif($c >= 240) { $bits = 4; }
1094 elseif($c >= 224) { $bits = 3; }
1095 elseif($c >= 192) { $bits = 2; }
1096 else { return false; }
1097 if(($i + $bits) > $len) { return false; }
1098 while($bits > 1) {
1099 $i++;
1100 $b = ord($str[$i]);
1101 if($b < 128 || $b > 191) { return false; }
1102 $bits--;
1103 }
1104 }
1105 }
1106 return true;
1107 }
1108
1109 function get_display_size()
1110 {
1111 $width = -1;
1112 $height = -1;
1113
1114 if ($this->tag !== 'img') {
1115 return false;
1116 }
1117
1118 // See if there is aheight or width attribute in the tag itself.
1119 if (isset($this->attr['width'])) {
1120 $width = $this->attr['width'];
1121 }
1122
1123 if (isset($this->attr['height'])) {
1124 $height = $this->attr['height'];
1125 }
1126
1127 // Now look for an inline style.
1128 if (isset($this->attr['style'])) {
1129 // Thanks to user gnarf from stackoverflow for this regular expression.
1130 $attributes = array();
1131
1132 preg_match_all(
1133 '/([\w-]+)\s*:\s*([^;]+)\s*;?/',
1134 $this->attr['style'],
1135 $matches,
1136 PREG_SET_ORDER
1137 );
1138
1139 foreach ($matches as $match) {
1140 $attributes[$match[1]] = $match[2];
1141 }
1142
1143 // If there is a width in the style attributes:
1144 if (isset($attributes['width']) && $width == -1) {
1145 // check that the last two characters are px (pixels)
1146 if (strtolower(substr($attributes['width'], -2)) === 'px') {
1147 $proposed_width = substr($attributes['width'], 0, -2);
1148 // Now make sure that it's an integer and not something stupid.
1149 if (filter_var($proposed_width, FILTER_VALIDATE_INT)) {
1150 $width = $proposed_width;
1151 }
1152 }
1153 }
1154
1155 // If there is a width in the style attributes:
1156 if (isset($attributes['height']) && $height == -1) {
1157 // check that the last two characters are px (pixels)
1158 if (strtolower(substr($attributes['height'], -2)) == 'px') {
1159 $proposed_height = substr($attributes['height'], 0, -2);
1160 // Now make sure that it's an integer and not something stupid.
1161 if (filter_var($proposed_height, FILTER_VALIDATE_INT)) {
1162 $height = $proposed_height;
1163 }
1164 }
1165 }
1166
1167 }
1168
1169 // Future enhancement:
1170 // Look in the tag to see if there is a class or id specified that has
1171 // a height or width attribute to it.
1172
1173 // Far future enhancement
1174 // Look at all the parent tags of this image to see if they specify a
1175 // class or id that has an img selector that specifies a height or width
1176 // Note that in this case, the class or id will have the img sub-selector
1177 // for it to apply to the image.
1178
1179 // ridiculously far future development
1180 // If the class or id is specified in a SEPARATE css file that's not on
1181 // the page, go get it and do what we were just doing for the ones on
1182 // the page.
1183
1184 $result = array(
1185 'height' => $height,
1186 'width' => $width
1187 );
1188
1189 return $result;
1190 }
1191
1192 function save($filepath = '')
1193 {
1194 $ret = $this->outertext();
1195
1196 if ($filepath !== '') {
1197 file_put_contents($filepath, $ret, LOCK_EX);
1198 }
1199
1200 return $ret;
1201 }
1202
1203 function addClass($class)
1204 {
1205 if (is_string($class)) {
1206 $class = explode(' ', $class);
1207 }
1208
1209 if (is_array($class)) {
1210 foreach($class as $c) {
1211 if (isset($this->class)) {
1212 if ($this->hasClass($c)) {
1213 continue;
1214 } else {
1215 $this->class .= ' ' . $c;
1216 }
1217 } else {
1218 $this->class = $c;
1219 }
1220 }
1221 }
1222 }
1223
1224 function hasClass($class)
1225 {
1226 if (is_string($class)) {
1227 if (isset($this->class)) {
1228 return in_array($class, explode(' ', $this->class), true);
1229 }
1230 }
1231
1232 return false;
1233 }
1234
1235 function removeClass($class = null)
1236 {
1237 if (!isset($this->class)) {
1238 return;
1239 }
1240
1241 if (is_null($class)) {
1242 $this->removeAttribute('class');
1243 return;
1244 }
1245
1246 if (is_string($class)) {
1247 $class = explode(' ', $class);
1248 }
1249
1250 if (is_array($class)) {
1251 $class = array_diff(explode(' ', $this->class), $class);
1252 if (empty($class)) {
1253 $this->removeAttribute('class');
1254 } else {
1255 $this->class = implode(' ', $class);
1256 }
1257 }
1258 }
1259
1260 function getAllAttributes()
1261 {
1262 return $this->attr;
1263 }
1264
1265 function getAttribute($name)
1266 {
1267 return $this->$name;
1268 }
1269
1270 function setAttribute($name, $value)
1271 {
1272 $this->$name = $value;
1273 }
1274
1275 function hasAttribute($name)
1276 {
1277 return isset($this->$name);
1278 }
1279
1280 function removeAttribute($name)
1281 {
1282 unset($this->$name);
1283 }
1284
1285 function remove()
1286 {
1287 if ($this->parent) {
1288 $this->parent->removeChild($this);
1289 }
1290 }
1291
1292 function removeChild($node)
1293 {
1294 foreach($node->children as $child) {
1295 $node->removeChild($child);
1296 }
1297
1298 // No need to re-index node->children because it is about to be removed!
1299
1300 foreach($node->nodes as $entity) {
1301 $enidx = array_search($entity, $node->nodes, true);
1302 $edidx = array_search($entity, $node->dom->nodes, true);
1303
1304 if ($enidx !== false) {
1305 unset($node->nodes[$enidx]);
1306 }
1307
1308 if ($edidx !== false) {
1309 unset($node->dom->nodes[$edidx]);
1310 }
1311 }
1312
1313 // No need to re-index node->nodes because it is about to be removed!
1314
1315 $nidx = array_search($node, $this->nodes, true);
1316 $cidx = array_search($node, $this->children, true);
1317 $didx = array_search($node, $this->dom->nodes, true);
1318
1319 if ($nidx !== false) {
1320 unset($this->nodes[$nidx]);
1321 }
1322
1323 $this->nodes = array_values($this->nodes);
1324
1325 if ($cidx !== false) {
1326 unset($this->children[$cidx]);
1327 }
1328
1329 $this->children = array_values($this->children);
1330
1331 if ($didx !== false) {
1332 unset($this->dom->nodes[$didx]);
1333 }
1334
1335 // Do not re-index dom->nodes because nodes point to other nodes in the
1336 // array explicitly!
1337
1338 $node->clear();
1339 }
1340
1341 function getElementById($id)
1342 {
1343 return $this->find("#$id", 0);
1344 }
1345
1346 function getElementsById($id, $idx = null)
1347 {
1348 return $this->find("#$id", $idx);
1349 }
1350
1351 function getElementByTagName($name)
1352 {
1353 return $this->find($name, 0);
1354 }
1355
1356 function getElementsByTagName($name, $idx = null)
1357 {
1358 return $this->find($name, $idx);
1359 }
1360
1361 function parentNode()
1362 {
1363 return $this->parent();
1364 }
1365
1366 function childNodes($idx = -1)
1367 {
1368 if ($idx === -1) {
1369 return $this->children;
1370 }
1371
1372 if (isset($this->children[$idx])) {
1373 return $this->children[$idx];
1374 }
1375
1376 return null;
1377 }
1378
1379 function firstChild()
1380 {
1381 if (count($this->children) > 0) {
1382 return $this->children[0];
1383 }
1384 return null;
1385 }
1386
1387 function lastChild()
1388 {
1389 if (count($this->children) > 0) {
1390 return end($this->children);
1391 }
1392 return null;
1393 }
1394
1395 function nextSibling()
1396 {
1397 if ($this->parent === null) {
1398 return null;
1399 }
1400
1401 $idx = array_search($this, $this->parent->children, true);
1402
1403 if ($idx !== false && isset($this->parent->children[$idx + 1])) {
1404 return $this->parent->children[$idx + 1];
1405 }
1406
1407 return null;
1408 }
1409
1410 function previousSibling()
1411 {
1412 if ($this->parent === null) {
1413 return null;
1414 }
1415
1416 $idx = array_search($this, $this->parent->children, true);
1417
1418 if ($idx !== false && $idx > 0) {
1419 return $this->parent->children[$idx - 1];
1420 }
1421
1422 return null;
1423
1424 }
1425
1426 function hasChildNodes()
1427 {
1428 return !empty($this->children);
1429 }
1430
1431 function nodeName()
1432 {
1433 return $this->tag;
1434 }
1435
1436 function appendChild($node)
1437 {
1438 $node->parent = $this;
1439 $this->nodes[] = $node;
1440 $this->children[] = $node;
1441
1442 if ($this->dom) { // Attach current node to DOM (recursively)
1443 $children = array($node);
1444
1445 while($children) {
1446 $child = array_pop($children);
1447 $children = array_merge($children, $child->children);
1448
1449 $this->dom->nodes[] = $child;
1450 $child->dom = $this->dom;
1451 $child->_[self::HDOM_INFO_BEGIN] = count($this->dom->nodes) - 1;
1452 $child->_[self::HDOM_INFO_END] = $child->_[self::HDOM_INFO_BEGIN];
1453 }
1454
1455 $this->dom->root->_[self::HDOM_INFO_END] = count($this->dom->nodes) - 1;
1456 }
1457
1458 return $this;
1459 }
1460
1461 }
1462