1<?php 2/** 3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved. 4 * 5 * This source code is licensed under the GPL license found in the 6 * COPYING file in the root directory of this source tree. 7 * 8 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 9 * @author ComboStrap <support@combostrap.com> 10 * 11 */ 12 13namespace ComboStrap; 14 15use dokuwiki\Extension\SyntaxPlugin; 16use syntax_plugin_combo_cell; 17 18/** 19 * An helper to create manipulate component and html attributes 20 * 21 * You can: 22 * * declare component attribute after parsing 23 * * declare Html attribute during parsing 24 * * output the final HTML attributes at the end of the process with the function {@link TagAttributes::toHTMLAttributeString()} 25 * 26 * Component attributes have precedence on HTML attributes. 27 * 28 * @package ComboStrap 29 */ 30class TagAttributes 31{ 32 /** 33 * @var string the alt attribute value (known as the title for dokuwiki) 34 */ 35 const TITLE_KEY = 'title'; 36 37 38 const TYPE_KEY = "type"; 39 const ID_KEY = "id"; 40 41 /** 42 * If not strict, no error is reported 43 */ 44 const STRICT = "strict"; 45 46 /** 47 * The logical attributes that: 48 * * are not becoming HTML attributes 49 * * are never deleted 50 * (ie internal reserved words) 51 * 52 * TODO: they should be advertised by the syntax component 53 */ 54 const RESERVED_ATTRIBUTES = [ 55 self::SCRIPT_KEY, // no script attribute for security reason 56 TagAttributes::TYPE_KEY, // type is the component class 57 MediaLink::LINKING_KEY, // internal to image 58 CacheMedia::CACHE_KEY, // internal also 59 \syntax_plugin_combo_webcode::RENDERING_MODE_ATTRIBUTE, 60 syntax_plugin_combo_cell::VERTICAL_ATTRIBUTE, 61 self::OPEN_TAG, 62 self::HTML_BEFORE, 63 self::HTML_AFTER, 64 Dimension::RATIO_ATTRIBUTE, 65 self::STRICT, 66 SvgDocument::PRESERVE_ATTRIBUTE, 67 \syntax_plugin_combo_link::CLICKABLE_ATTRIBUTE, 68 MarkupRef::PREVIEW_ATTRIBUTE, 69 \syntax_plugin_combo_link::ATTRIBUTE_HREF_TYPE, 70 Skin::SKIN_ATTRIBUTE, 71 ColorRgb::PRIMARY_VALUE, 72 ColorRgb::SECONDARY_VALUE, 73 Dimension::ZOOM_ATTRIBUTE 74 ]; 75 76 /** 77 * The inline element 78 * We could pass the plugin object into tag attribute in place of the logical tag 79 * and check if the {@link SyntaxPlugin::getPType()} is normal 80 */ 81 const INLINE_LOGICAL_ELEMENTS = [ 82 ImageSvg::CANONICAL, 83 ImageRaster::CANONICAL, 84 Image::CANONICAL, 85 \syntax_plugin_combo_link::TAG, // link button for instance 86 \syntax_plugin_combo_button::TAG, 87 \syntax_plugin_combo_heading::TAG 88 ]; 89 const SCRIPT_KEY = "script"; 90 const TRANSFORM = "transform"; 91 92 const CANONICAL = "tag"; 93 94 const CLASS_KEY = "class"; 95 const WIKI_ID = "wiki-id"; 96 97 /** 98 * The open tag attributes 99 * permit to not close the tag in {@link TagAttributes::toHtmlEnterTag()} 100 * 101 * It's used for instance by the {@link \syntax_plugin_combo_tooltip} 102 * to advertise that it will add attribute and close it 103 */ 104 const OPEN_TAG = "open-tag"; 105 106 /** 107 * If an attribute has this value, 108 * it will not be added to the output (ie {@link TagAttributes::toHtmlEnterTag()}) 109 * Child element can unset attribute this way 110 * in order to write their own 111 * 112 * This is used by the {@link \syntax_plugin_combo_tooltip} 113 * to advertise that the title attribute should not be set 114 */ 115 const UN_SET = "unset"; 116 117 /** 118 * When wrapping an element 119 * A tag may get HTML before and after 120 * Uses for instance to wrap a svg in span 121 * when adding a {@link \syntax_plugin_combo_tooltip} 122 */ 123 const HTML_BEFORE = "htmlBefore"; 124 const HTML_AFTER = "htmlAfter"; 125 126 /** 127 * Attribute with multiple values 128 */ 129 const MULTIPLE_VALUES_ATTRIBUTES = [self::CLASS_KEY, self::REL]; 130 131 /** 132 * Link relation attributes 133 * https://html.spec.whatwg.org/multipage/links.html#linkTypes 134 */ 135 const REL = "rel"; 136 const STYLE_ATTRIBUTE = "style"; 137 138 139 /** 140 * A global static counter 141 * to {@link TagAttributes::generateAndSetId()} 142 */ 143 private static $counter = 0; 144 145 146 /** 147 * @var array attribute that were set on a component 148 */ 149 private $componentAttributesCaseInsensitive; 150 151 /** 152 * @var array the style declaration array 153 */ 154 private $styleDeclaration = array(); 155 156 /** 157 * @var bool - set when the transformation from component attribute to html attribute 158 * was done to avoid circular problem 159 */ 160 private $componentToHtmlAttributeProcessingWasDone = false; 161 162 /** 163 * @var array - output attribute are not the parsed attributes known as componentAttribute) 164 * They are created by the {@link TagAttributes::toHtmlArray()} processing mainly 165 */ 166 private $outputAttributes = array(); 167 168 /** 169 * @var array - the final html array 170 */ 171 private $finalHtmlArray = array(); 172 173 /** 174 * @var string the functional tag to which the attributes applies 175 * It's not an HTML tag (a div can have a flex display or a block and they don't carry this information) 176 * The tag gives also context for the attributes (ie an div has no natural width while an img has) 177 */ 178 private $logicalTag; 179 180 /** 181 * An html that should be added after the enter tag 182 * (used for instance to add metadata such as backgrounds, illustration image for cards ,... 183 * @var string 184 */ 185 private $htmlAfterEnterTag; 186 187 /** 188 * Use to make the difference between 189 * an HTTP call for a media (ie SVG) vs an HTTP call for a page (HTML) 190 */ 191 const TEXT_HTML_MIME = "text/html"; 192 private $mime = TagAttributes::TEXT_HTML_MIME; 193 194 /** 195 * @var bool - adding the default class for the logical tag 196 */ 197 private $defaultStyleClassShouldBeAdded = true; 198 199 200 /** 201 * ComponentAttributes constructor. 202 * Use the static create function to instantiate this object 203 * @param $tag - tag (the tag gives context for the attributes 204 * * an div has no natural width while an img has 205 * * this is not always the component name / syntax name (for instance the {@link \syntax_plugin_combo_codemarkdown} is another syntax 206 * for a {@link \syntax_plugin_combo_code} and have therefore the same logical name) 207 * @param array $componentAttributes 208 */ 209 private function __construct(array $componentAttributes = array(), $tag = null) 210 { 211 $this->logicalTag = $tag; 212 $this->componentAttributesCaseInsensitive = new ArrayCaseInsensitive($componentAttributes); 213 214 /** 215 * Delete null values 216 * Empty string, 0 may exist 217 */ 218 foreach ($componentAttributes as $key => $value) { 219 if (is_null($value)) { 220 unset($this->componentAttributesCaseInsensitive[$key]); 221 continue; 222 } 223 if ($key === self::STYLE_ATTRIBUTE) { 224 unset($this->componentAttributesCaseInsensitive[$key]); 225 $stylingProperties = explode(";", $value); 226 foreach ($stylingProperties as $stylingProperty) { 227 if (empty($stylingProperty)) { 228 // case with a trailing comma. ie `width:18rem;` 229 continue; 230 } 231 [$key, $value] = preg_split("/:/", $stylingProperty, 2); 232 $this->addStyleDeclarationIfNotSet($key, $value); 233 } 234 } 235 } 236 237 } 238 239 /** 240 * @param $match - the {@link SyntaxPlugin::handle()} match 241 * @param array $defaultAttributes 242 * @param array|null $knownTypes 243 * @return TagAttributes 244 */ 245 public static function createFromTagMatch($match, array $defaultAttributes = [], array $knownTypes = null): TagAttributes 246 { 247 $inlineHtmlAttributes = PluginUtility::getTagAttributes($match, $knownTypes); 248 $tag = PluginUtility::getTag($match); 249 $mergedAttributes = PluginUtility::mergeAttributes($inlineHtmlAttributes, $defaultAttributes); 250 return self::createFromCallStackArray($mergedAttributes, $tag); 251 } 252 253 254 public static function createEmpty($logicalTag = "") 255 { 256 if ($logicalTag !== "") { 257 return new TagAttributes([], $logicalTag); 258 } else { 259 return new TagAttributes(); 260 } 261 } 262 263 /** 264 * @param array|null $callStackArray - an array of key value pair 265 * @param string|null $logicalTag - the logical tag for which this attribute will apply 266 * @return TagAttributes 267 */ 268 public static function createFromCallStackArray(?array $callStackArray, string $logicalTag = null): TagAttributes 269 { 270 if ($callStackArray === null) { 271 $callStackArray = []; 272 } 273 if (!is_array($callStackArray)) { 274 LogUtility::msg("The renderArray variable passed is not an array ($callStackArray)"); 275 $callStackArray = []; 276 } 277 return new TagAttributes($callStackArray, $logicalTag); 278 } 279 280 281 /** 282 * For CSS a unit is mandatory (not for HTML or SVG attributes) 283 * @param $value 284 * @return string return a CSS property with pixel as unit if the unit is not specified 285 */ 286 public static function toQualifiedCssValue($value): string 287 { 288 /** 289 * A length value may be also `fit-content` 290 * we just check that if there is only number, 291 * we add the pixel 292 * Same as {@link is_numeric()} ? 293 */ 294 if (is_numeric($value)) { 295 return $value . "px"; 296 } else { 297 return $value; 298 } 299 300 } 301 302 /** 303 * Function used to normalize the attribute name to the combostrap attribute name 304 * @param $name 305 * @return mixed|string 306 */ 307 public static function AttributeNameFromDokuwikiToCombo($name) 308 { 309 switch ($name) { 310 case "w": 311 return Dimension::WIDTH_KEY; 312 case "h": 313 return Dimension::HEIGHT_KEY; 314 default: 315 return $name; 316 } 317 } 318 319 /** 320 * Clone a tag attributes 321 * Tag Attributes are used for request and for response 322 * To avoid conflict, a function should clone it before 323 * calling the final method {@link TagAttributes::toHtmlArray()} 324 * or {@link TagAttributes::toHtmlEnterTag()} 325 * @param TagAttributes $tagAttributes 326 * @return TagAttributes 327 */ 328 public static function createFromTagAttributes(TagAttributes $tagAttributes): TagAttributes 329 { 330 $newTagAttributes = new TagAttributes($tagAttributes->getComponentAttributes(), $tagAttributes->getLogicalTag()); 331 foreach ($tagAttributes->getStyleDeclarations() as $property => $value) { 332 $newTagAttributes->addStyleDeclarationIfNotSet($property, $value); 333 } 334 return $newTagAttributes; 335 } 336 337 /** 338 * Merge class name 339 * @param string $newNames - the name that we want to add 340 * @param ?string $actualNames - the actual names 341 * @return string - the class name list 342 * 343 * for instance: 344 * * newNames = foo blue 345 * * actual Name = foo bar 346 * return 347 * * foo bar blue 348 */ 349 static function mergeClassNames(string $newNames, ?string $actualNames): string 350 { 351 if (!is_string($newNames)) { 352 LogUtility::msg("The value ($newNames) for the `class` attribute is not a string", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 353 } 354 /** 355 * It may be in the form "value1 value2" 356 */ 357 $newValues = StringUtility::explodeAndTrim($newNames, " "); 358 if (!empty($actualNames)) { 359 $actualValues = StringUtility::explodeAndTrim(trim($actualNames), " "); 360 } else { 361 $actualValues = []; 362 } 363 $newValues = PluginUtility::mergeAttributes($newValues, $actualValues); 364 return implode(" ", $newValues); 365 } 366 367 public function addClassName($className): TagAttributes 368 { 369 370 $this->addComponentAttributeValue(self::CLASS_KEY, $className); 371 return $this; 372 373 } 374 375 public function getClass() 376 { 377 return $this->getValue(self::CLASS_KEY); 378 } 379 380 public function getStyle(): ?string 381 { 382 if (sizeof($this->styleDeclaration) != 0) { 383 return PluginUtility::array2InlineStyle($this->styleDeclaration); 384 } else { 385 /** 386 * null is needed to see if the attribute was set or not 387 * because an attribute may have the empty string 388 * Example: the wiki id of the root namespace 389 */ 390 return null; 391 } 392 393 } 394 395 public function getStyleDeclarations(): array 396 { 397 return $this->styleDeclaration; 398 399 } 400 401 /** 402 * Add an attribute with its value if the value is not empty 403 * @param $attributeName 404 * @param $attributeValue 405 * @return TagAttributes 406 */ 407 public function addComponentAttributeValue($attributeName, $attributeValue): TagAttributes 408 { 409 410 if (empty($attributeValue) && !is_bool($attributeValue)) { 411 LogUtility::msg("The value of the attribute ($attributeName) is empty. Use the nonEmpty function instead if it's the wanted behavior", LogUtility::LVL_MSG_WARNING, "support"); 412 } 413 414 $attLower = strtolower($attributeName); 415 $actual = null; 416 if ($this->hasComponentAttribute($attLower)) { 417 $actual = $this->componentAttributesCaseInsensitive[$attLower]; 418 } 419 420 /** 421 * Type of data: list (class) or atomic (id) 422 */ 423 if (in_array($attributeName, self::MULTIPLE_VALUES_ATTRIBUTES)) { 424 $this->componentAttributesCaseInsensitive[$attLower] = self::mergeClassNames($attributeValue, $actual); 425 } else { 426 if (!empty($actual)) { 427 LogUtility::msg("The attribute ($attLower) stores an unique value and has already a value ($actual). to set another value ($attributeValue), use the `set` operation instead", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 428 } 429 $this->componentAttributesCaseInsensitive[$attLower] = $attributeValue; 430 } 431 432 return $this; 433 434 } 435 436 public function setComponentAttributeValue($attributeName, $attributeValue) 437 { 438 $attLower = strtolower($attributeName); 439 $actualValue = $this->getValue($attributeName); 440 if ($actualValue === null || $actualValue !== TagAttributes::UN_SET) { 441 $this->componentAttributesCaseInsensitive[$attLower] = $attributeValue; 442 } 443 } 444 445 public function addComponentAttributeValueIfNotEmpty($attributeName, $attributeValue) 446 { 447 if (!empty($attributeValue)) { 448 $this->addComponentAttributeValue($attributeName, $attributeValue); 449 } 450 } 451 452 public function hasComponentAttribute($attributeName) 453 { 454 $isset = isset($this->componentAttributesCaseInsensitive[$attributeName]); 455 if ($isset === false) { 456 /** 457 * Edge effect 458 * if this is a boolean value and the first value, it may be stored in the type 459 */ 460 if (isset($this->componentAttributesCaseInsensitive[TagAttributes::TYPE_KEY])) { 461 if ($attributeName == $this->componentAttributesCaseInsensitive[TagAttributes::TYPE_KEY]) { 462 return true; 463 } 464 } 465 } 466 return $isset; 467 } 468 469 /** 470 * To an HTML array in the form 471 * class => 'value1 value2', 472 * att => 'value1 value 2' 473 * For historic reason, data passed between the handle and the render 474 * can still be in this format 475 */ 476 public function toHtmlArray(): array 477 { 478 if ($this->componentToHtmlAttributeProcessingWasDone) { 479 LogUtility::msg("This tag attribute ($this) was already finalized. You cannot finalized it twice", LogUtility::LVL_MSG_ERROR); 480 return $this->finalHtmlArray; 481 } 482 483 $this->componentToHtmlAttributeProcessingWasDone = true; 484 485 /** 486 * Width and height 487 */ 488 Dimension::processWidthAndHeight($this); 489 490 /** 491 * Process animation (onHover, onView) 492 */ 493 Hover::processOnHover($this); 494 Animation::processOnView($this); 495 496 497 /** 498 * Position and Stickiness 499 */ 500 Position::processStickiness($this); 501 Position::processPosition($this); 502 Display::processDisplay($this); 503 504 /** 505 * Block processing 506 * 507 * Float, align, spacing 508 */ 509 FloatAttribute::processFloat($this); 510 Align::processAlignAttributes($this); 511 Spacing::processSpacingAttributes($this); 512 Opacity::processOpacityAttribute($this); 513 Background::processBackgroundAttributes($this); 514 Shadow::process($this); 515 516 /** 517 * Process text attributes 518 */ 519 LineSpacing::processLineSpacingAttributes($this); 520 TextAlign::processTextAlign($this); 521 Boldness::processBoldnessAttribute($this); 522 FontSize::processFontSizeAttribute($this); 523 TextColor::processTextColorAttribute($this); 524 Underline::processUnderlineAttribute($this); 525 526 /** 527 * Process the style attributes if any 528 */ 529 PluginUtility::processStyle($this); 530 Toggle::processToggle($this); 531 532 533 /** 534 * Skin Attribute 535 */ 536 Skin::processSkinAttribute($this); 537 538 /** 539 * Lang 540 */ 541 Lang::processLangAttribute($this); 542 543 /** 544 * Transform 545 */ 546 if ($this->hasComponentAttribute(self::TRANSFORM)) { 547 $transformValue = $this->getValueAndRemove(self::TRANSFORM); 548 $this->addStyleDeclarationIfNotSet("transform", $transformValue); 549 } 550 551 /** 552 * Tooltip 553 */ 554 Tooltip::processTooltip($this); 555 556 /** 557 * Add the type class used for CSS styling 558 */ 559 StyleUtility::addStylingClass($this); 560 561 /** 562 * Add the style has html attribute 563 * before processing 564 */ 565 $this->addOutputAttributeValueIfNotEmpty("style", $this->getStyle()); 566 567 /** 568 * Create a non-sorted temporary html attributes array 569 */ 570 $tempHtmlArray = $this->outputAttributes; 571 572 /** 573 * copy the unknown component attributes 574 */ 575 $originalArray = $this->componentAttributesCaseInsensitive->getOriginalArray(); 576 foreach ($originalArray as $key => $value) { 577 578 // Null Value, not needed 579 if (is_null($value)) { 580 continue; 581 } 582 583 // No overwrite 584 if (isset($tempHtmlArray[$key])) { 585 continue; 586 } 587 588 // Reserved attribute 589 if (!in_array($key, self::RESERVED_ATTRIBUTES)) { 590 $tempHtmlArray[$key] = $value; 591 } 592 593 } 594 595 596 /** 597 * Sort by attribute 598 * https://datacadamia.com/web/html/attribute#order 599 */ 600 $sortedArray = array(); 601 $once = "once"; 602 $multiple = "multiple"; 603 $orderPatterns = [ 604 "class" => $once, 605 "id" => $once, 606 "name" => $once, 607 "data-.*" => $multiple, 608 "src.*" => $multiple, 609 "for" => $once, 610 "type" => $once, 611 "href" => $once, 612 "value" => $once, 613 "title" => $once, 614 "alt" => $once, 615 "role" => $once, 616 "aria-*" => $multiple]; 617 foreach ($orderPatterns as $pattern => $type) { 618 foreach ($tempHtmlArray as $name => $value) { 619 $searchPattern = "^$pattern$"; 620 if (preg_match("/$searchPattern/", $name)) { 621 unset($tempHtmlArray[$name]); 622 if ($type === $once) { 623 $sortedArray[$name] = $value; 624 continue 2; 625 } else { 626 $multipleValues[$name] = $value; 627 } 628 } 629 } 630 if (!empty($multipleValues)) { 631 ksort($multipleValues); 632 $sortedArray = array_merge($sortedArray, $multipleValues); 633 $multipleValues = []; 634 } 635 } 636 foreach ($tempHtmlArray as $name => $value) { 637 638 if (!is_null($value)) { 639 /** 640 * 641 * Don't add a filter on the empty values 642 * 643 * The value of an HTML attribute may be empty 644 * Example the wiki id of the root namespace 645 * 646 * By default, {@link TagAttributes::addOutputAttributeValue()} 647 * will not accept any value, it must be implicitly said with the 648 * {@link TagAttributes::addOutputAttributeValue()} 649 * 650 */ 651 $sortedArray[$name] = $value; 652 } 653 654 } 655 $this->finalHtmlArray = $sortedArray; 656 657 /** 658 * To Html attribute encoding 659 */ 660 $this->finalHtmlArray = $this->encodeToHtmlValue($this->finalHtmlArray); 661 662 return $this->finalHtmlArray; 663 664 } 665 666 /** 667 * 668 * 669 * @param $key 670 * @param $value 671 * @return TagAttributes 672 */ 673 public function addOutputAttributeValue($key, $value): TagAttributes 674 { 675 if (blank($value)) { 676 LogUtility::msg("The value of the output attribute is blank for the key ($key) - Tag ($this->logicalTag). Use the empty function if the value can be empty", LogUtility::LVL_MSG_ERROR); 677 } 678 $this->outputAttributes[$key] = $value; 679 return $this; 680 } 681 682 683 public function addOutputAttributeValueIfNotEmpty($key, $value) 684 { 685 if (!empty($value)) { 686 $this->addOutputAttributeValue($key, $value); 687 } 688 } 689 690 /** 691 * @param $attributeName 692 * @param null $default 693 * @return string|array|null a HTML value in the form 'value1 value2...' 694 */ 695 public function getValue($attributeName, $default = null) 696 { 697 $attributeName = strtolower($attributeName); 698 if ($this->hasComponentAttribute($attributeName)) { 699 return $this->componentAttributesCaseInsensitive[$attributeName]; 700 } else { 701 return $default; 702 } 703 } 704 705 /** 706 * @return array - the storage format returned from the {@link SyntaxPlugin::handle()} method 707 */ 708 public function toInternalArray() 709 { 710 return $this->componentAttributesCaseInsensitive; 711 } 712 713 /** 714 * Get the value and remove it from the attributes 715 * @param $attributeName 716 * @param $default 717 * @return string|array|null 718 */ 719 public function getValueAndRemove($attributeName, $default = null) 720 { 721 $attributeName = strtolower($attributeName); 722 $value = $default; 723 if ($this->hasComponentAttribute($attributeName)) { 724 $value = $this->getValue($attributeName); 725 726 if (!in_array($attributeName, self::RESERVED_ATTRIBUTES)) { 727 /** 728 * Don't remove for instance the `type` 729 * because it may be used elsewhere 730 */ 731 unset($this->componentAttributesCaseInsensitive[$attributeName]); 732 } else { 733 LogUtility::msg("Internal: The attribute $attributeName is a reserved word and cannot be removed. Use the get function instead", LogUtility::LVL_MSG_WARNING, "support"); 734 } 735 736 } 737 return $value; 738 } 739 740 741 /** 742 * @return array - an array of key string and value of the component attributes 743 * This array is saved on the disk 744 */ 745 public function toCallStackArray(): array 746 { 747 $array = array(); 748 $originalArray = $this->componentAttributesCaseInsensitive->getOriginalArray(); 749 foreach ($originalArray as $key => $value) { 750 /** 751 * Only null value are not passed 752 * width can be zero, wiki-id can be the empty string (ie root namespace) 753 */ 754 if (!is_null($value)) { 755 $array[$key] = StringUtility::toString($value); 756 } 757 } 758 /** 759 * html attribute may also be in the callstack 760 */ 761 foreach ($this->outputAttributes as $key => $value) { 762 $array[$key] = StringUtility::toString($value); 763 } 764 $style = $this->getStyle(); 765 if ($style != null) { 766 $array["style"] = $style; 767 } 768 return $array; 769 } 770 771 public 772 function getComponentAttributeValue($attributeName, $default = null) 773 { 774 $lowerAttribute = strtolower($attributeName); 775 $value = $default; 776 if ($this->hasComponentAttribute($lowerAttribute)) { 777 $value = $this->getValue($lowerAttribute); 778 } 779 return $value; 780 } 781 782 public 783 function addStyleDeclarationIfNotSet($property, $value) 784 { 785 ArrayUtility::addIfNotSet($this->styleDeclaration, $property, $value); 786 } 787 788 789 public 790 function hasStyleDeclaration($styleDeclaration): bool 791 { 792 return isset($this->styleDeclaration[$styleDeclaration]); 793 } 794 795 public 796 function getAndRemoveStyleDeclaration($styleDeclaration) 797 { 798 $styleValue = $this->styleDeclaration[$styleDeclaration]; 799 unset($this->styleDeclaration[$styleDeclaration]); 800 return $styleValue; 801 } 802 803 804 public 805 function toHTMLAttributeString(): string 806 { 807 808 $tagAttributeString = ""; 809 810 $htmlArray = $this->toHtmlArray(); 811 foreach ($htmlArray as $name => $value) { 812 813 /** 814 * Empty value are authorized 815 * null are just not set 816 */ 817 if (!is_null($value)) { 818 819 /** 820 * Unset attribute should not be added 821 */ 822 if ($value === TagAttributes::UN_SET) { 823 continue; 824 } 825 826 /** 827 * The condition is important 828 * because we may pass the javascript character `\n` in a `srcdoc` for javascript 829 * and the {@link StringUtility::toString()} will transform it as `\\n` 830 * making it unusable 831 */ 832 if (!is_string($value)) { 833 $stringValue = StringUtility::toString($value); 834 } else { 835 $stringValue = $value; 836 } 837 838 839 $tagAttributeString .= $name . '="' . $stringValue . '" '; 840 } 841 842 } 843 return trim($tagAttributeString); 844 845 846 } 847 848 public 849 function getComponentAttributes(): array 850 { 851 return $this->toCallStackArray(); 852 } 853 854 public 855 function removeComponentAttributeIfPresent($attributeName) 856 { 857 if ($this->hasComponentAttribute($attributeName)) { 858 unset($this->componentAttributesCaseInsensitive[$attributeName]); 859 } 860 861 } 862 863 public 864 function toHtmlEnterTag($htmlTag): string 865 { 866 867 $enterTag = "<" . $htmlTag; 868 $attributeString = $this->toHTMLAttributeString(); 869 if (!empty($attributeString)) { 870 $enterTag .= " " . $attributeString; 871 } 872 /** 873 * Is it an open tag ? 874 */ 875 if (!$this->getValue(self::OPEN_TAG, false)) { 876 877 $enterTag .= ">"; 878 879 /** 880 * Do we have html after the tag is closed 881 */ 882 if (!empty($this->htmlAfterEnterTag)) { 883 $enterTag .= DOKU_LF . $this->htmlAfterEnterTag; 884 } 885 886 } 887 888 889 return $enterTag; 890 891 } 892 893 public 894 function toHtmlEmptyTag($htmlTag): string 895 { 896 897 $enterTag = "<" . $htmlTag; 898 $attributeString = $this->toHTMLAttributeString(); 899 if (!empty($attributeString)) { 900 $enterTag .= " " . $attributeString; 901 } 902 return $enterTag . "/>"; 903 904 } 905 906 public 907 function getLogicalTag() 908 { 909 return $this->logicalTag; 910 } 911 912 public 913 function setLogicalTag($tag): TagAttributes 914 { 915 $this->logicalTag = $tag; 916 return $this; 917 } 918 919 public 920 function removeComponentAttribute($attribute) 921 { 922 $lowerAtt = strtolower($attribute); 923 if (isset($this->componentAttributesCaseInsensitive[$lowerAtt])) { 924 $value = $this->componentAttributesCaseInsensitive[$lowerAtt]; 925 unset($this->componentAttributesCaseInsensitive[$lowerAtt]); 926 return $value; 927 } else { 928 /** 929 * Edge case, this is the first boolean attribute 930 * and may has been categorized as the type 931 */ 932 if (!$this->getType() == $lowerAtt) { 933 LogUtility::msg("Internal Error: The component attribute ($attribute) is not present. Use the ifPresent function, if you don't want this message", LogUtility::LVL_MSG_ERROR); 934 } 935 936 } 937 938 } 939 940 /** 941 * @param $html - an html that should be closed and added after the enter tag 942 */ 943 public 944 function addHtmlAfterEnterTag($html) 945 { 946 $this->htmlAfterEnterTag = $html . $this->htmlAfterEnterTag; 947 } 948 949 /** 950 * The mime of the HTTP request 951 * This is not the good place but yeah, 952 * this class has become the context class 953 * 954 * Mime make the difference for a svg to know if it's required as external resource (ie SVG) 955 * or as included in HTML page 956 * @param $mime 957 */ 958 public 959 function setMime($mime) 960 { 961 $this->mime = $mime; 962 } 963 964 /** 965 * @return string - the mime of the request 966 */ 967 public 968 function getMime() 969 { 970 return $this->mime; 971 } 972 973 public 974 function getType() 975 { 976 return $this->getValue(self::TYPE_KEY); 977 } 978 979 /** 980 * @param $attributeName 981 * @return ConditionalValue 982 */ 983 public 984 function getConditionalValueAndRemove($attributeName) 985 { 986 $value = $this->getConditionalValueAndRemove($attributeName); 987 return new ConditionalValue($value); 988 989 } 990 991 /** 992 * @param $attributeName 993 * @return false|string[] - an array of values 994 */ 995 public 996 function getValuesAndRemove($attributeName) 997 { 998 999 /** 1000 * Trim 1001 */ 1002 $trim = trim($this->getValueAndRemove($attributeName)); 1003 1004 /** 1005 * Replace all suite of space that have more than 2 characters 1006 */ 1007 $value = preg_replace("/\s{2,}/", " ", $trim); 1008 return explode(" ", $value); 1009 1010 } 1011 1012 public 1013 function setType($type): TagAttributes 1014 { 1015 $this->setComponentAttributeValue(TagAttributes::TYPE_KEY, $type); 1016 return $this; 1017 } 1018 1019 /** 1020 * Merging will add the values, no replace or overwrite 1021 * @param $callStackArray 1022 */ 1023 public 1024 function mergeWithCallStackArray($callStackArray) 1025 { 1026 foreach ($callStackArray as $key => $value) { 1027 1028 if ($this->hasComponentAttribute($key)) { 1029 $isMultipleAttributeValue = in_array($key, self::MULTIPLE_VALUES_ATTRIBUTES); 1030 if ($isMultipleAttributeValue) { 1031 $this->addComponentAttributeValue($key, $value); 1032 } 1033 } else { 1034 $this->setComponentAttributeValue($key, $value); 1035 } 1036 } 1037 1038 } 1039 1040 /** 1041 * @param $string 1042 * @return TagAttributes 1043 */ 1044 public 1045 function removeAttributeIfPresent($string): TagAttributes 1046 { 1047 $this->removeComponentAttributeIfPresent($string); 1048 $this->removeHTMLAttributeIfPresent($string); 1049 return $this; 1050 1051 } 1052 1053 private 1054 function removeHTMLAttributeIfPresent($string) 1055 { 1056 $lowerAtt = strtolower($string); 1057 if (isset($this->outputAttributes[$lowerAtt])) { 1058 unset($this->outputAttributes[$lowerAtt]); 1059 } 1060 } 1061 1062 public 1063 function getValueAndRemoveIfPresent($attribute, $default = null) 1064 { 1065 $value = $this->getValue($attribute, $default); 1066 $this->removeAttributeIfPresent($attribute); 1067 return $value; 1068 } 1069 1070 public 1071 function generateAndSetId() 1072 { 1073 self::$counter += 1; 1074 $id = self::$counter; 1075 $logicalTag = $this->getLogicalTag(); 1076 if (!empty($logicalTag)) { 1077 $id = $this->logicalTag . $id; 1078 } 1079 $this->setComponentAttributeValue("id", $id); 1080 return $id; 1081 } 1082 1083 /** 1084 * 1085 * @param $markiTag 1086 * @return string - the marki tag made of logical attribute 1087 * There is no processing to transform it to an HTML tag 1088 */ 1089 public 1090 function toMarkiEnterTag($markiTag) 1091 { 1092 $enterTag = "<" . $markiTag; 1093 1094 $attributeString = ""; 1095 foreach ($this->getComponentAttributes() as $key => $value) { 1096 $attributeString .= "$key=\"$value\" "; 1097 } 1098 $attributeString = trim($attributeString); 1099 1100 if (!empty($attributeString)) { 1101 $enterTag .= " " . $attributeString; 1102 } 1103 $enterTag .= ">"; 1104 return $enterTag; 1105 1106 } 1107 1108 /** 1109 * @param string $key add an html attribute with the empty string 1110 */ 1111 public 1112 function addEmptyOutputAttributeValue($key) 1113 { 1114 1115 $this->outputAttributes[$key] = ''; 1116 return $this; 1117 1118 } 1119 1120 public 1121 function addEmptyComponentAttributeValue($attribute) 1122 { 1123 $this->componentAttributesCaseInsensitive[$attribute] = ""; 1124 } 1125 1126 /** 1127 * @param $attribute 1128 * @param null $default 1129 * @return mixed 1130 */ 1131 public 1132 function getBooleanValueAndRemoveIfPresent($attribute, $default = null) 1133 { 1134 $value = $this->getValueAndRemoveIfPresent($attribute); 1135 if ($value === null) { 1136 return $default; 1137 } else { 1138 return DataType::toBoolean($value); 1139 } 1140 } 1141 1142 public 1143 function hasAttribute($attribute) 1144 { 1145 $hasAttribute = $this->hasComponentAttribute($attribute); 1146 if ($hasAttribute === true) { 1147 return true; 1148 } else { 1149 return $this->hasHtmlAttribute($attribute); 1150 } 1151 } 1152 1153 private 1154 function hasHtmlAttribute($attribute): bool 1155 { 1156 return isset($this->outputAttributes[$attribute]); 1157 } 1158 1159 /** 1160 * Encoding should happen always to the target format output. 1161 * ie HTML 1162 * 1163 * If it's user or not data. 1164 * 1165 * Sanitizing is completely useless. We follow the same principal than SQL parameters 1166 * 1167 * We follows the rule 2 to encode the unknown value 1168 * We encode the component attribute to the target output (ie HTML) 1169 * 1170 * @param array $arrayToEscape 1171 * @param null $subKey 1172 * 1173 * 1174 * 1175 * 1176 * https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-2-attribute-encode-before-inserting-untrusted-data-into-html-common-attributes 1177 * 1178 * @return array 1179 */ 1180 private 1181 function encodeToHtmlValue(array $arrayToEscape, $subKey = null): array 1182 { 1183 1184 $returnedArray = []; 1185 foreach ($arrayToEscape as $name => $value) { 1186 1187 $encodedName = PluginUtility::htmlEncode($name); 1188 1189 /** 1190 * Boolean does not need to be encoded 1191 */ 1192 if (is_bool($value)) { 1193 if ($subKey == null) { 1194 $returnedArray[$encodedName] = $value; 1195 } else { 1196 $returnedArray[$subKey][$encodedName] = $value; 1197 } 1198 continue; 1199 } 1200 1201 /** 1202 * 1203 * Browser bug in a srcset 1204 * 1205 * In the HTML attribute srcset (not in the img src), if we set, 1206 * ``` 1207 * http://nico.lan/_media/docs/metadata/metadata_manager.png?w=355&h=176&tseed=1636624852&tok=af396a 355w 1208 * ``` 1209 * the request is encoded ***by the browser**** one more time and the server gets: 1210 * * `&&h = 176` 1211 * * php create therefore the property 1212 * * `&h = 176` 1213 * * and note `h = 176` 1214 */ 1215 $encodeValue = true; 1216 if ($encodedName === "srcset" && !PluginUtility::isTest()) { 1217 /** 1218 * Our test xhtml processor does not support non ampersand encoded character 1219 */ 1220 $encodeValue = false; 1221 } 1222 if ($encodeValue) { 1223 $value = PluginUtility::htmlEncode($value); 1224 } 1225 if ($subKey == null) { 1226 $returnedArray[$encodedName] = $value; 1227 } else { 1228 $returnedArray[$subKey][$encodedName] = $value; 1229 } 1230 1231 } 1232 return $returnedArray; 1233 1234 } 1235 1236 public function __toString() 1237 { 1238 return "TagAttributes"; 1239 } 1240 1241 /** 1242 * @throws ExceptionCombo 1243 */ 1244 public function getValueAsInteger(string $WIDTH_KEY, ?int $default = null): ?int 1245 { 1246 $value = $this->getValue($WIDTH_KEY, $default); 1247 if ($value === null) { 1248 return null; 1249 } 1250 return DataType::toInteger($value); 1251 } 1252 1253 public function hasClass(string $string): bool 1254 { 1255 return strpos($this->getClass(), $string) !== false; 1256 } 1257 1258 public function getDefaultStyleClassShouldBeAdded(): bool 1259 { 1260 return $this->defaultStyleClassShouldBeAdded; 1261 } 1262 1263 public function setDefaultStyleClassShouldBeAdded(bool $bool): TagAttributes 1264 { 1265 $this->defaultStyleClassShouldBeAdded = $bool; 1266 return $this; 1267 } 1268 1269 1270} 1271