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&amp;h=176&amp;tseed=1636624852&amp;tok=af396a 355w
1208             * ```
1209             * the request is encoded ***by the browser**** one more time and the server gets:
1210             *   * `&amp;&amp;h  =   176`
1211             *   * php create therefore the property
1212             *      * `&amp;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