1<?php
2
3namespace ComboStrap\Xml;
4
5use ComboStrap\ArrayCaseInsensitive;
6use ComboStrap\ArrayUtility;
7use ComboStrap\ExceptionBadArgument;
8use ComboStrap\ExceptionBadSyntax;
9use ComboStrap\ExceptionNotEquals;
10use ComboStrap\ExceptionNotFound;
11use ComboStrap\ExceptionRuntime;
12use ComboStrap\Html;
13use ComboStrap\StringUtility;
14use ComboStrap\TagAttribute\StyleAttribute;
15use ComboStrap\Web\Url;
16use ComboStrap\Xml\XmlSystems;
17use DOMElement;
18use DOMText;
19
20class XmlElement
21{
22
23    private DOMElement $domElement;
24    private XmlDocument $document;
25    private array $styleDeclaration = [];
26
27    /**
28     * @param DOMElement $domElement - the dom element wrapped
29     * @param XmlDocument $document - the document
30     */
31    public function __construct(DOMElement $domElement, XmlDocument $document)
32    {
33        $this->domElement = $domElement;
34        $this->document = $document;
35
36    }
37
38    public static function create($domElement, XmlDocument $xmlDocument): XmlElement
39    {
40        return new XmlElement($domElement, $xmlDocument);
41    }
42
43    public function getAttribute(string $qualifiedName): string
44    {
45        return $this->domElement->getAttribute($qualifiedName);
46    }
47
48    public function getClass(): string
49    {
50        return $this->domElement->getAttribute("class");
51    }
52
53    /**
54     * @throws ExceptionNotFound
55     */
56    public function getFirstChildElement(): XmlElement
57    {
58        $firstChild = $this->domElement->firstChild;
59        while (!($firstChild instanceof DOMElement)) {
60            if ($firstChild === null) {
61                throw new ExceptionNotFound("No first child element");
62            }
63            $firstChild = $firstChild->nextSibling;
64        }
65        return new XmlElement($firstChild, $this->document);
66    }
67
68    /**
69     * @return XmlElement[]
70     */
71    public function getChildrenElement(): array
72    {
73        $childNodes = [];
74        foreach ($this->domElement->childNodes as $childNode) {
75            if ($childNode instanceof DOMElement) {
76                $childNodes[] = new XmlElement($childNode, $this->document);
77            }
78        }
79        return $childNodes;
80    }
81
82    /**
83     * @return array - the node text values in an array
84     */
85    public function getChildrenNodeTextValues(): array
86    {
87        $childNodes = [];
88        foreach ($this->domElement->childNodes as $childNode) {
89            if ($childNode instanceof DOMText) {
90                $childNodes[] = $childNode->nodeValue;
91            } else {
92                $childNodes[] = implode('', XmlElement::create($childNode, $this->document)->getChildrenNodeTextValues());
93            }
94        }
95        return $childNodes;
96    }
97
98    /**
99     * @return XmlElement[]
100     * @throws ExceptionBadSyntax
101     */
102    public function querySelectorAll(string $selector): array
103    {
104        $xpath = $this->document->cssSelectorToXpath($selector);
105        $nodes = [];
106        foreach ($this->document->xpath($xpath, $this->domElement) as $child) {
107            if ($child instanceof DOMElement) {
108                $nodes[] = new XmlElement($child, $this->document);
109            }
110        }
111        return $nodes;
112    }
113
114    public function getXmlTextNormalized(): string
115    {
116
117        return $this->document->toXmlNormalized($this->domElement);
118
119    }
120
121    public function removeAttribute($attributeName): XmlElement
122    {
123        $attr = $this->domElement->getAttributeNode($attributeName);
124        if ($attr == false) {
125            return $this;
126        }
127        $result = $this->domElement->removeAttributeNode($attr);
128        if ($result === false) {
129            throw new ExceptionRuntime("Not able to delete the attribute $attributeName of the node element {$this->domElement->tagName} in the Xml document");
130        }
131        return $this;
132    }
133
134    public function remove(): XmlElement
135    {
136        $this->domElement->parentNode->removeChild($this->domElement);
137        return $this;
138    }
139
140    public function getStyle(): string
141    {
142        return $this->domElement->getAttribute("style");
143    }
144
145    public function getNodeValue()
146    {
147        return $this->domElement->nodeValue;
148    }
149
150    /**
151     * @throws ExceptionBadSyntax
152     * @throws ExceptionNotFound
153     */
154    public function querySelector(string $selector): XmlElement
155    {
156        $domNodeList = $this->querySelectorAll($selector);
157        if (sizeof($domNodeList) >= 1) {
158            return $domNodeList[0];
159        }
160        throw new ExceptionNotFound("No element was found with the selector $selector");
161    }
162
163    public function getLocalName()
164    {
165        return $this->domElement->localName;
166    }
167
168    public function addClass(string $class): XmlElement
169    {
170        $classes = Html::mergeClassNames($class, $this->getClass());
171        $this->domElement->setAttribute("class", $classes);
172        return $this;
173    }
174
175    public function setAttribute(string $name, string $value): XmlElement
176    {
177        $this->domElement->setAttribute($name, $value);
178        return $this;
179    }
180
181    public function hasAttribute(string $name): bool
182    {
183        return $this->domElement->hasAttribute($name);
184    }
185
186    public function getDomElement(): DOMElement
187    {
188        return $this->domElement;
189    }
190
191    /**
192     * Append a text node as a child
193     * @param string $string - the text
194     * @param string $position - the position on where to insert the text node
195     * @return $this
196     * @throws ExceptionBadArgument - if the text is not a valid text xml expression
197     */
198    public function insertAdjacentTextNode(string $string, string $position = 'afterbegin'): XmlElement
199    {
200        $textNode = $this->domElement->ownerDocument->createTextNode($string);
201        $this->insertAdjacentDomElement($position, $textNode);
202        return $this;
203    }
204
205    public function toHtml()
206    {
207        return $this->domElement->ownerDocument->saveHTML($this->domElement);
208    }
209
210    public function toXhtml()
211    {
212        return $this->domElement->ownerDocument->saveXML($this->domElement);
213    }
214
215    public function getNodeValueWithoutCdata()
216    {
217        return XmlSystems::extractTextWithoutCdata($this->getNodeValue());
218    }
219
220    /**
221     * @throws ExceptionBadSyntax
222     * @throws ExceptionBadArgument
223     */
224    public function insertAdjacentHTML(string $position, string $html): XmlElement
225    {
226        $externalElement = XmlDocument::createHtmlDocFromMarkup($html)->getElement()->getDomElement();
227        // import/copy item from external document to internal document
228        $internalElement = $this->importIfExternal($externalElement);
229        $this->insertAdjacentDomElement($position, $internalElement);
230        return $this;
231    }
232
233
234    public function getId(): string
235    {
236        return $this->getAttribute("id");
237    }
238
239    public function hasChildrenElement(): bool
240    {
241        return sizeof($this->getChildrenElement()) !== 0;
242    }
243
244    public function getAttributeOrDefault(string $string, string $default): string
245    {
246        if (!$this->hasAttribute($string)) {
247            return $default;
248        }
249        return $this->getAttribute($string);
250
251    }
252
253    public function appendChild(XmlElement $xmlElement): XmlElement
254    {
255        $element = $this->importIfExternal($xmlElement->domElement);
256        $this->domElement->appendChild($element);
257        return $this;
258    }
259
260    public function getDocument(): XmlDocument
261    {
262        return $this->document;
263    }
264
265    public function setNodeValue(string $nodeValue)
266    {
267        $this->domElement->nodeValue = $nodeValue;
268    }
269
270    public function addStyle(string $name, string $value): XmlElement
271    {
272        ArrayUtility::addIfNotSet($this->styleDeclaration, $name, $value);
273        $this->setAttribute("style", Html::array2InlineStyle($this->styleDeclaration));
274        return $this;
275    }
276
277    /**
278     *
279     * Utility to change the owner document
280     * otherwise you get an error:
281     * ```
282     * DOMException : Wrong Document Error
283     * ```
284     *
285     * @param DOMElement $domElement
286     * @return DOMElement
287     */
288    private function importIfExternal(DOMElement $domElement): DOMElement
289    {
290        if ($domElement->ownerDocument !== $this->getDocument()->getDomDocument()) {
291            return $this->getDocument()->getDomDocument()->importNode($domElement, true);
292        }
293        return $domElement;
294    }
295
296    /**
297     * @throws ExceptionNotEquals
298     */
299    public function equals(XmlElement $rightDocument, array $attributeFilter = [])
300    {
301        $error = "";
302        XmlSystems::diffNode(
303            $this->domElement,
304            $rightDocument->domElement,
305            $error,
306            $attributeFilter
307        );
308        if ($error !== null) {
309            throw new ExceptionNotEquals($error);
310        }
311    }
312
313    public function removeClass(string $string): XmlElement
314    {
315        $class = $this->getClass();
316        $newClass = str_replace($string, "", $class);
317        $this->setAttribute("class", $newClass);
318        return $this;
319    }
320
321    /**
322     * @throws ExceptionNotFound
323     */
324    public function getParent(): XmlElement
325    {
326        $parentNode = $this->domElement->parentNode;
327        if ($parentNode !== null) {
328            while (!($parentNode instanceof DOMElement)) {
329                $parentNode = $parentNode->parentNode;
330                if ($parentNode === null) {
331                    break;
332                }
333            }
334        }
335        if ($parentNode === null) {
336            throw new ExceptionNotFound("No parent node found");
337        }
338        return new XmlElement($parentNode, $this->document);
339    }
340
341    /**
342     * @param string $position
343     * @param \DOMNode $domNode - ie {@Link \DOMElement} or {@link \DOMNode}
344     * @return XmlElement
345     * @throws ExceptionBadArgument
346     */
347    public function insertAdjacentDomElement(string $position, \DOMNode $domNode): XmlElement
348    {
349        switch ($position) {
350            case 'beforeend':
351                $this->domElement->appendChild($domNode);
352                return $this;
353            case 'afterbegin':
354                $firstChild = $this->domElement->firstChild;
355                if ($firstChild === null) {
356                    $this->domElement->appendChild($domNode);
357                } else {
358                    // The object on which you actually call the insertBefore()
359                    // on the parent node of the reference node
360                    // otherwise you get a `not found`
361                    // https://www.php.net/manual/en/domnode.insertbefore.php#53506
362                    $firstChild->parentNode->insertBefore($domNode, $firstChild);
363                }
364                return $this;
365            case 'beforebegin':
366                $this->domElement->parentNode->insertBefore($domNode, $this->domElement);
367                return $this;
368            default:
369                throw new ExceptionBadArgument("The position ($position) is unknown");
370        }
371    }
372
373    public function getInnerText(): string
374    {
375        if ($this->hasChildrenElement()) {
376            return implode('', $this->getChildrenNodeTextValues());
377        } else {
378            return $this->domElement->nodeValue;
379        }
380    }
381
382    public function getInnerTextWithoutCdata()
383    {
384        return XmlSystems::extractTextWithoutCdata($this->getInnerText());
385    }
386
387    public function getStyleProperties(): ArrayCaseInsensitive
388    {
389        $source = StyleAttribute::HtmlStyleValueToArray($this->getStyle());
390        return new ArrayCaseInsensitive($source);
391    }
392
393    public function getStyleProperty(string $property): string
394    {
395        return $this->getStyleProperties()[$property];
396    }
397
398    /**
399     * @throws ExceptionBadSyntax
400     * @throws ExceptionBadArgument
401     */
402    public function getAttributeAsUrl(string $attributeName): Url
403    {
404        $value = $this->getAttribute($attributeName);
405        if (empty($value)) {
406            return Url::createEmpty();
407        }
408        return Url::createFromString($value);
409    }
410
411    public function __toString()
412    {
413        $toString = $this->getLocalName();
414        $class = $this->getClass();
415        if ($class !== "") {
416            $classes = StringUtility::explodeAndTrim($class, " ");
417            $toString .= '.' . implode(".", $classes);
418        }
419        if ($this->getId() !== "") {
420            $toString .= "#" . $this->getId();
421        }
422        return $toString;
423    }
424
425    public function hasClass(string $needleClass): bool
426    {
427        $classes = preg_split("/\s/", $this->getClass());
428        if (in_array($needleClass, $classes)) {
429            return true;
430        }
431        return false;
432    }
433
434
435}
436