Browse Source

Simplified NodeList, added NamedNodeMap

wrapper-classes
Dustin Wilson 3 years ago
parent
commit
6fba662683
  1. 109
      lib/Collection.php
  2. 116
      lib/Document.php
  3. 105
      lib/Element.php
  4. 132
      lib/HTMLCollection.php
  5. 5
      lib/HTMLTemplateElement.php
  6. 8
      lib/InnerNode/Document.php
  7. 76
      lib/NamedNodeMap.php
  8. 56
      lib/Node.php
  9. 123
      lib/NodeList.php
  10. 52
      tests/cases/TestDocument.php
  11. 92
      tests/cases/TestNode.php
  12. 1
      tests/phpunit.dist.xml

109
lib/Collection.php

@ -0,0 +1,109 @@
<?php
/**
* @license MIT
* Copyright 2017 Dustin Wilson, J. King, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\Framework\MagicProperties,
MensBeam\HTML\DOM\InnerNode\Document as InnerDocument;
# A collection is an object that represents a list of nodes. A collection can be
# either live or static. Unless otherwise stated, a collection must be live.
#
# If a collection is live, then the attributes and methods on that object must
# operate on the actual underlying data, not a snapshot of the data.
#
# When a collection is created, a filter and a root are associated with it.
#
# The collection then represents a view of the subtree rooted at the
# collection’s root, containing only nodes that match the given filter. The view
# is linear. In the absence of specific requirements to the contrary, the nodes
# within the collection must be sorted in tree order.
/**
* Not in standard except as an abstract description of HTMLCollection and
* NodeList. Exists to eliminate code duplication between HTMLCollection and
* NodeList.
*/
abstract class Collection implements \ArrayAccess, \Countable, \Iterator {
use MagicProperties;
protected InnerDocument $innerDocument;
protected \DOMNodeList|\DOMNamedNodeMap $innerCollection;
protected ?\Closure $filter = null;
protected int $_length = 0;
protected ?array $nodeArray = null;
protected int $position = 0;
protected function __get_length(): int {
# The length attribute must return the number of nodes represented by the
# collection.
return $this->count();
}
protected function __construct(InnerDocument $innerDocument, \DOMNodeList $nodeList) {
$this->innerDocument = $innerDocument;
$this->innerCollection = $nodeList;
}
public function count(): int {
return $this->innerCollection->length;
}
public function current(): ?Node {
return $this->item($this->position);
}
public function item(int $index): ?Node {
# The item(index) method must return the indexth node in the collection. If
# there is no indexth node in the collection, then the method must return null.
// PHP's DOM does this okay already
$node = $this->innerCollection->item($index);
if ($node === null) {
return null;
}
return $this->innerDocument->getWrapperNode($node);
}
public function key(): int {
return $this->position;
}
public function next(): void {
$this->position++;
}
public function rewind(): void {
$this->position = 0;
}
public function offsetExists($offset): bool {
return ($this->innerCollection->item($offset) !== null);
}
public function offsetGet($offset): ?Node {
return $this->item($offset);
}
public function offsetSet($offset, $value): void {
// Collections are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function offsetUnset($offset): void {
// Collections are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function valid(): bool {
return $this->offsetExists($this->position);
}
}

116
lib/Document.php

@ -95,35 +95,7 @@ class Document extends Node {
$localName = strtolower($localName);
}
// Before we do the next step we need to work around a PHP DOM bug. PHP DOM
// cannot create attribute nodes if there's no document element. So, create the
// attribute node in a separate document which does have a document element and
// then import
$target = $this->innerNode;
$documentElement = $this->documentElement;
if ($documentElement === null) {
$target = new \DOMDocument();
$target->appendChild($target->createElement('html'));
}
# 3. Return a new attribute whose local name is localName and node document is
# this.
// We need to do a couple more things here. PHP's XML-based DOM doesn't allow
// some characters. We have to coerce them sometimes.
try {
$attr = $target->createAttributeNS(null, $localName);
} catch (\DOMException $e) {
// The element name is invalid for XML
// Replace any offending characters with "UHHHHHH" where H are the
// uppercase hexadecimal digits of the character's code point
$attr = $target->createAttributeNS(null, $this->coerceName($localName));
}
if ($documentElement === null) {
return $this->importNode($attr);
}
return $this->innerNode->getWrapperNode($attr);
return $this->__createAttribute(null, $localName);
}
public function createAttributeNS(?string $namespace, string $qualifiedName): Attr {
@ -134,35 +106,7 @@ class Document extends Node {
[ 'namespace' => $namespace, 'prefix' => $prefix, 'localName' => $localName ] = $this->validateAndExtract($qualifiedName, $namespace);
$qualifiedName = ($prefix) ? "$prefix:$localName" : $localName;
// Before we do the next step we need to work around a PHP DOM bug. PHP DOM
// cannot create attribute nodes if there's no document element. So, create the
// attribute node in a separate document which does have a document element and
// then import
$target = $this->innerNode;
$documentElement = $this->documentElement;
if ($documentElement === null) {
$target = new \DOMDocument();
$target->appendChild($target->createElement('html'));
}
# 2. Return a new attribute whose namespace is namespace, namespace prefix is
# prefix, local name is localName, and node document is this.
// We need to do a couple more things here. PHP's XML-based DOM doesn't allow
// some characters. We have to coerce them sometimes.
try {
$attr = $target->createAttributeNS($namespace, $qualifiedName);
} catch (\DOMException $e) {
// The element name is invalid for XML
// Replace any offending characters with "UHHHHHH" where H are the
// uppercase hexadecimal digits of the character's code point
$attr = $target->createAttributeNS($namespace, $this->coerceName($qualifiedName));
}
if ($documentElement === null) {
return $this->importNode($attr);
}
return $this->innerNode->getWrapperNode($attr);
return $this->__createAttribute($namespace, $qualifiedName);
}
public function createCDATASection(string $data): CDATASection {
@ -284,17 +228,59 @@ class Document extends Node {
}
public function importNode(\DOMNode|Node $node, bool $deep = false): Node {
$isDOMNode = ($node instanceof \DOMNode);
$node = $this->innerNode->getWrapperNode($this->innerNode->importNode((!$isDOMNode) ? $this->getInnerNode($node) : $node, false));
if ($node instanceof Element || $node instanceof DocumentFragment) {
$this->convertAdoptedOrImportedNode($node, $isDOMNode);
# The importNode(node, deep) method steps are:
#
# 1. If node is a document or shadow root, then throw a "NotSupportedError"
# DOMException.
if ($node instanceof Document || $node instanceof \DOMDocument) {
throw new DOMException(DOMException::NOT_SUPPORTED);
}
return $node;
# 2. Return a clone of node, with this and the clone children flag set if deep
# is true.
// PHP's DOM mostly does this correctly. It, however, won't import doctypes...
if ($node instanceof DocumentType || $node instanceof \DOMDocumentType) {
return $this->implementation->createDocumentType($node->name, $node->publicId, $node->systemId);
}
return $this->innerNode->getWrapperNode($this->innerNode->importNode((!$node instanceof \DOMNode) ? $this->getInnerNode($node) : $node, $deep));
}
protected function convertAdoptedOrImportedNode(Node $node, bool $originalWasDOMNode = false): Node {
protected function __createAttribute(?string $namespace, string $qualifiedName): Attr {
// Before we do the next step we need to work around a PHP DOM bug. PHP DOM
// cannot create attribute nodes if there's no document element. So, create the
// attribute node in a separate document which does have a document element and
// then import
$target = $this->innerNode;
$documentElement = $this->documentElement;
if ($documentElement === null) {
$target = new \DOMDocument();
$target->appendChild($target->createElement('html'));
}
// From createAttributeNS:
# 2. Return a new attribute whose namespace is namespace, namespace prefix is
# prefix, local name is localName, and node document is this.
// We need to do a couple more things here. PHP's XML-based DOM doesn't allow
// some characters. We have to coerce them sometimes.
try {
$attr = $target->createAttributeNS($namespace, $qualifiedName);
} catch (\DOMException $e) {
// The element name is invalid for XML
// Replace any offending characters with "UHHHHHH" where H are the
// uppercase hexadecimal digits of the character's code point
$attr = $target->createAttributeNS($namespace, $this->coerceName($qualifiedName));
}
if ($documentElement === null) {
return $this->importNode($attr);
}
return $this->innerNode->getWrapperNode($attr);
}
/*protected function convertAdoptedOrImportedNode(Node $node, bool $originalWasDOMNode = false): Node {
// Yet another PHP DOM hang-up that is either a bug or a feature. When
// elements are imported their id attributes aren't able to be picked up by
// getElementById, so let's fix that.
@ -330,5 +316,5 @@ class Document extends Node {
}
return $node;
}
}*/
}

105
lib/Element.php

@ -7,25 +7,19 @@
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\HTML\DOM\InnerNode\Reflection,
MensBeam\HTML\Parser;
use MensBeam\HTML\DOM\InnerNode\{
Document as InnerDocument,
Reflection
};
use MensBeam\HTML\Parser;
class Element extends Node {
use ChildNode, DocumentOrElement, ParentNode;
protected function __get_attributes(): NodeList {
// NodeLists cannot be created from their constructors normally.
$doc = ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument;
return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', function() use($doc) {
$result = [];
$innerAttributes = $this->innerNode->attributes;
foreach ($innerAttributes as $i) {
$result[] = $doc->getWrapperNode($i);
}
return $result;
});
protected function __get_attributes(): NamedNodeMap {
// NamedNodeMaps cannot be created from their constructors normally.
return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NamedNodeMap', $this, ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument, $this->innerNode->attributes);
}
protected function __get_localName(): ?string {
@ -77,6 +71,43 @@ class Element extends Node {
return $value;
}
public function setAttribute(string $qualifiedName, string $value): void {
# 1. If qualifiedName does not match the Name production in XML, then throw an
# "InvalidCharacterError" DOMException.
if (preg_match(InnerDocument::NAME_PRODUCTION_REGEX, $qualifiedName) !== 1) {
throw new DOMException(DOMException::INVALID_CHARACTER);
}
# 2. If this is in the HTML namespace and its node document is an HTML document,
# then set qualifiedName to qualifiedName in ASCII lowercase.
if (!$this instanceof XMLDocument) {
$qualifiedName = strtolower($qualifiedName);
}
# 3. Let attribute be the first attribute in this’s attribute list whose
# qualified name is qualifiedName, and null otherwise.
# 4. If attribute is null, create an attribute whose local name is qualifiedName,
# value is value, and node document is this’s node document, then append this
# attribute to this, and then return.
# 5. Change attribute to value.
// Going to try to handle this by getting the PHP DOM to do the heavy lifting
// when we can because it's faster.
try {
$this->innerNode->setAttributeNS(null, $qualifiedName, $value);
} catch (\DOMException $e) {
// The attribute name is invalid for XML
// Replace any offending characters with "UHHHHHH" where H are the uppercase
// hexadecimal digits of the character's code point
$this->innerNode->setAttributeNS(null, $this->coerceName($qualifiedName), $value);
}
// If you create an id attribute this way it won't be used by PHP in
// getElementById, so let's fix that.
if ($qualifiedName === 'id') {
$this->innerNode->setIdAttribute($qualifiedName, true);
}
}
public function setAttributeNode(Attr $attr): ?Attr {
# The setAttributeNode(attr) and setAttributeNodeNS(attr) methods steps are to
# return the result of setting an attribute given attr and this.
@ -87,4 +118,50 @@ class Element extends Node {
public function setAttributeNodeNS(Attr $attr): ?Attr {
return $this->setAttributeNode($attr);
}
public function setAttributeNS(?string $namespace, string $qualifiedName, string $value): void {
# 1. Let namespace, prefix, and localName be the result of passing namespace and
# qualifiedName to validate and extract.
[ 'namespace' => $namespace, 'prefix' => $prefix, 'localName' => $localName ] = $this->validateAndExtract($qualifiedName, $namespace);
$qualifiedName = ($prefix === null || $prefix === '') ? $localName : "{$prefix}:{$localName}";
# 2. Set an attribute value for this using localName, value, and also prefix and
# namespace.
// Going to try to handle this by getting the PHP DOM to do the heavy lifting
// when we can because it's faster.
// NOTE: We create attribute nodes so that xmlns attributes
// don't get lost; otherwise they cannot be serialized
if ($namespace === Parser::XMLNS_NAMESPACE) {
// Xmlns attributes have special bugs just for them. How lucky! Xmlns attribute
// nodes won't stick and can actually cause segmentation faults if created on a
// no longer existing document element, appended to another element, and then
// retrieved. So, use the methods used in Document::createAttributeNS to get an
// attribute node.
$a = $this->ownerDocument->createAttributeNS($namespace, $qualifiedName);
$a->value = $this->escapeString($value, true);
$this->setAttributeNodeNS($a);
} else {
try {
$this->innerNode->setAttributeNS($namespace, $qualifiedName, $value);
} catch (\DOMException $e) {
// The attribute name is invalid for XML
// Replace any offending characters with "UHHHHHH" where H are the
// uppercase hexadecimal digits of the character's code point
if ($namespace !== null) {
$qualifiedName = implode(':', array_map([$this, 'coerceName'], explode(':', $qualifiedName, 2)));
} else {
$qualifiedName = $this->coerceName($qualifiedName);
}
$this->innerNode->setAttributeNS($namespace, $qualifiedName, $value);
}
}
if ($namespace === null) {
// If you create an id attribute this way it won't be used by PHP in
// getElementById, so let's fix that.
if ($qualifiedName === 'id') {
$this->innerNode->setIdAttribute($qualifiedName, true);
}
}
}
}

132
lib/HTMLCollection.php

@ -7,103 +7,17 @@
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\Framework\MagicProperties,
MensBeam\HTML\Parser;
use MensBeam\HTML\Parser;
# An HTMLCollection object is a collection of elements.
#
# NOTE: HTMLCollection is a historical artifact we cannot rid the web of. While
# developers are of course welcome to keep using it, new API standard designers
# ought not to use it (use sequence<T> in IDL instead).
#
# A collection is an object that represents a list of nodes. A collection can be
# either live or static. Unless otherwise stated, a collection must be live.
#
# If a collection is live, then the attributes and methods on that object must
# operate on the actual underlying data, not a snapshot of the data.
#
# When a collection is created, a filter and a root are associated with it.
#
# The collection then represents a view of the subtree rooted at the
# collection’s root, containing only nodes that match the given filter. The view
# is linear. In the absence of specific requirements to the contrary, the nodes
# within the collection must be sorted in tree order.
class HTMLCollection implements \ArrayAccess, \Countable, \Iterator {
use MagicProperties;
protected ?\Closure $filter = null;
protected int $_length = 0;
protected ?array $nodeArray = null;
protected int $position = 0;
protected function __get_length(): int {
# The length attribute must return the number of nodes represented by the
# collection.
return $this->count();
}
protected function __construct(array|\Closure $arrayOrClosure = []) {
// In this implementation the root part of the creation is handled either before
// the NodeList is created (array) or within the filter (\Closure).
if ($arrayOrClosure === null) {
$arrayOrClosure = [];
}
if (is_callable($arrayOrClosure)) {
$this->filter = $arrayOrClosure;
} else {
// Check types while also unpacking the iterable.
$array = [];
foreach ($arrayOrClosure as $i) {
if (!$i instanceof Element) {
$type = gettype($i);
if ($type === 'object') {
$type = get_class($i);
}
throw new Exception(Exception::ARGUMENT_TYPE_ERROR, 1, 'arrayOrClosure', 'array<Element>|\\Closure<array<Element>>', $type);
}
$array[] = $i;
}
$this->nodeArray = $array;
$this->_length = count($array);
}
}
public function count(): int {
if ($this->nodeArray !== null) {
return $this->_length;
}
$nodeArray = ($this->filter)();
return count($nodeArray);
}
class HTMLCollection extends Collection {
public function current(): ?Element {
return $this->item($this->position);
return parent::current();
}
public function item(int $index): ?Element {
# The item(index) method must return the indexth node in the collection. If
# there is no indexth node in the collection, then the method must return null.
if ($index >= $this->count()) {
return null;
}
$nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)();
if (array_key_exists($index, $nodeArray)) {
return $nodeArray[$index];
}
return null;
}
public function key(): int {
return $this->position;
return parent::item($index);
}
public function namedItem(string $name): ?Element {
@ -119,48 +33,16 @@ class HTMLCollection implements \ArrayAccess, \Countable, \Iterator {
# • it has an ID which is key;
# • it is in the HTML namespace and has a name attribute whose value is key;
# or null if there is no such element.
$nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)();
foreach ($nodeArray as $element) {
if ($element->getAttribute('id') === $name || $element->namespaceURI === Parser::HTML_NAMESPACE && $element->getAttribute('name') === $name) {
return $element;
foreach ($this->innerCollection as $element) {
if ($element->getAttribute('id') === $name || $element->namespaceURI === null && $element->getAttribute('name') === $name) {
return $this->innerDocument->getWrapperNode($element);
}
}
return null;
}
public function next(): void {
$this->position++;
}
public function rewind(): void {
$this->position = 0;
}
public function offsetExists($offset): bool {
if (is_int($offset)) {
$nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)();
return array_key_exists($offset, $nodeArray);
}
return ($this->namedItem($offset) !== null);
}
public function offsetGet($offset): ?Element {
return (is_int($offset)) ? $this->item($offset) : $this->namedItem($offset);
}
public function offsetSet($offset, $value): void {
// NodeLists are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function offsetUnset($offset): void {
// NodeLists are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function valid() {
$this->offsetExists($this->position);
}
}

5
lib/HTMLTemplateElement.php

@ -7,8 +7,7 @@
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\HTML\DOM\InnerNode\Element as InnerElement,
MensBeam\HTML\DOM\InnerNode\Reflection;
use MensBeam\HTML\DOM\InnerNode\Reflection;
class HTMLTemplateElement extends HTMLElement {
@ -21,7 +20,7 @@ class HTMLTemplateElement extends HTMLElement {
}
protected function __construct(InnerElement $element) {
protected function __construct(\DOMElement $element) {
parent::__construct($element);
$this->_content = $this->ownerDocument->createDocumentFragment();

8
lib/InnerNode/Document.php

@ -19,6 +19,8 @@ use MensBeam\HTML\Parser;
class Document extends \DOMDocument {
use MagicProperties;
// Used for validation. Not sure where to put them where they wouldn't be
// exposed unnecessarily to the public API.
public const NAME_PRODUCTION_REGEX = '/^[:A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][:A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*$/Su';
public const QNAME_PRODUCTION_REGEX = '/^([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*:)?[A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*$/Su';
@ -104,7 +106,11 @@ class Document extends \DOMDocument {
Reflection::setProtectedProperties($wrapperNode, [ '_ownerDocument' => $this->_wrapperNode ]);
}
$this->nodeMap->set($wrapperNode, $node);
// Don't put documents into the node map cache to prevent circular references.
if ($className !== 'Document') {
$this->nodeMap->set($wrapperNode, $node);
}
return $wrapperNode;
}
}

76
lib/NamedNodeMap.php

@ -0,0 +1,76 @@
<?php
/**
* @license MIT
* Copyright 2017 Dustin Wilson, J. King, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\HTML\DOM\InnerNode\Document as InnerDocument;
class NamedNodeMap extends Collection {
# A NamedNodeMap has an associated element (an element).
protected Element $element;
protected function __construct(Element $element, InnerDocument $innerDocument, \DOMNamedNodeMap $namedNodeMap) {
$this->element = $element;
$this->innerDocument = $innerDocument;
$this->innerCollection = $namedNodeMap;
}
public function current(): ?Attr {
return parent::current();
}
public function getNamedItem(string $qualifiedName): ?Attr {
# The getNamedItem(qualifiedName) method steps are to return the result of
# getting an attribute given qualifiedName and element.
return $this->element->getAttributeNode($qualifiedName);
}
public function getNamedItemNS(?string $namespace, string $localName): ?Attr {
# The getNamedItemNS(namespace, localName) method steps are to return the result
# of getting an attribute given namespace, localName, and element.
return $this->element->getAttributeNodeNS($namespace, $localName);
}
public function item(int $index): ?Attr {
return parent::item($index);
}
public function removeNamedItem(string $qualifiedName): ?Attr {
return $this->removeNamedItemNS(null, $qualifiedName);
}
public function removeNamedItemNS(?string $namespace, string $localName): ?Attr {
# The removeNamedItem(qualifiedName) method steps are:
#
# 1. Let attr be the result of removing an attribute given namespace, localName,
# and element.
$attr = $this->element->removeAttributeNode($namespace, $localName);
# 2. If attr is null, then throw a "NotFoundError" DOMException.
if ($attr === null) {
throw new DOMException(DOMException::NOT_FOUND);
}
# 3. Return attr.
return $attr;
}
public function setNamedItem(string $attr): ?Attr {
# The setNamedItem(attr) and setNamedItemNS(attr) method steps are to return the
# result of setting an attribute given attr and element.
return $this->element->setAttributeNode($attr);
}
public function setNamedItemNS(string $attr): ?Attr {
# The setNamedItem(attr) and setNamedItemNS(attr) method steps are to return the
# result of setting an attribute given attr and element.
return $this->element->setAttributeNode($attr);
}
}

56
lib/Node.php

@ -35,7 +35,6 @@ abstract class Node {
public const DOCUMENT_POSITION_CONTAINED_BY = 0x10;
public const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20;
protected ?NodeList $_childNodes = null;
protected \DOMNode $innerNode;
private static ?int $rand = null;
@ -73,29 +72,7 @@ abstract class Node {
}
protected function __get_childNodes(): NodeList {
// NodeLists cannot be created from their constructors normally.
// OPTIMIZATION: Going to optimize here and only create a truly live NodeList if
// the node is even capable of having children, otherwise will just be an empty
// NodeList. There is no sense in generating a live list that will never update.
if ($this instanceof Document || $this instanceof DocumentFragment || $this instanceof Element) {
$doc = ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument;
return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', function() use($doc) {
$result = [];
$innerChildNodes = $this->innerNode->childNodes;
foreach ($innerChildNodes as $i) {
$result[] = $doc->getWrapperNode($i);
}
return $result;
});
}
if ($this->_childNodes !== null) {
return $this->_childNodes;
}
return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\Nodelist', []);
return $this->_childNodes;
return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument, $this->innerNode->childNodes);
}
protected function __get_firstChild(): ?Node {
@ -323,6 +300,10 @@ abstract class Node {
public function cloneNode(?bool $deep = false): Node {
# The cloneNode(deep) method steps are:
// PHP's DOM mostly does this correctly with the exception of not cloning
// doctypes. However, the entire process needs to be done manually because of
// templates.
# 1. If this is a shadow root, then throw a "NotSupportedError" DOMException.
// DEVIATION: There is no scripting in this implementation
@ -335,16 +316,14 @@ abstract class Node {
// No need for this step. There will always be a provided document
# 2. If node is an element, then:
if ($this instanceof Element) {
# 1. Let copy be the result of creating an element, given document, node’s local
# name, node’s namespace, node’s namespace prefix, and node’s is value, with the
# synchronous custom elements flag unset.
# 2. For each attribute in node’s attribute list:
# 1. Let copyAttribute be a clone of attribute.
# 2. Append copyAttribute to copy.
// PHP's DOM can do this part correctly by shallow cloning.
$copy = $this->innerNode->ownerDocument->getWrapperNode($this->innerNode->cloneNode());
}
# 1. Let copy be the result of creating an element, given document, node’s local
# name, node’s namespace, node’s namespace prefix, and node’s is value, with the
# synchronous custom elements flag unset.
# 2. For each attribute in node’s attribute list:
# 1. Let copyAttribute be a clone of attribute.
# 2. Append copyAttribute to copy.
// PHP's DOM can do this part correctly by shallow cloning, so it will be
// handled instead in the "Otherwise" section of step #3.
# 3. Otherwise, let copy be a node that implements the same interfaces as node, and
# fulfills these additional requirements, switching on the interface node
@ -352,7 +331,7 @@ abstract class Node {
#
# ↪ Document
# Set copy’s encoding, content type, URL, origin, type, and mode to those of node.
elseif ($this instanceof Document) {
if ($this instanceof Document) {
$copy = $this->innerNode->getWrapperNode($this->innerNode->cloneNode());
if ($this->characterSet !== 'UTF-8' || $this->compatMode !== 'CSS1Compat' || $this->contentType !== 'text/html' || $this->URL !== '') {
@ -410,7 +389,7 @@ abstract class Node {
# contents, with document set to copy's template contents's node document, and
# with the clone children flag set.
# 3. Append copied contents to copy's template contents.
$copy->content = $this->content->cloneNode(true);
$copy->content->appendChild($this->content->cloneNode(true));
}
# 6. If the clone children flag is set, clone all the children of node and append
@ -419,12 +398,11 @@ abstract class Node {
if ($this instanceof Document) {
$childNodes = $this->childNodes;
foreach ($childNodes as $child) {
$copy->appendChild($child->importNode(true));
$copy->appendChild($copy->importNode($child, true));
}
} elseif ($this instanceof DocumentFragment || $this instanceof Element) {
$childNodes = $this->childNodes;
foreach ($childNodes as $child) {
var_export($child);
$copy->appendChild($child->cloneNode(true));
}
}
@ -639,7 +617,7 @@ abstract class Node {
# • Each child of A equals the child of B at the identical index.
foreach ($this->childNodes as $key => $child) {
$other = $otherNode->childNodes[$key];
if ($child->name !== $other->name || $child->publicId !== $other->publicId || $child->systemId !== $other->systemId) {
if (!$child->isEqualNode($other)) {
return false;
}
}

123
lib/NodeList.php

@ -7,128 +7,7 @@
declare(strict_types=1);
namespace MensBeam\HTML\DOM;
use MensBeam\Framework\MagicProperties;
# A NodeList object is a collection of nodes.
#
# A collection is an object that represents a list of nodes. A collection can be
# either live or static. Unless otherwise stated, a collection must be live.
#
# If a collection is live, then the attributes and methods on that object must
# operate on the actual underlying data, not a snapshot of the data.
#
# When a collection is created, a filter and a root are associated with it.
#
# The collection then represents a view of the subtree rooted at the
# collection’s root, containing only nodes that match the given filter. The view
# is linear. In the absence of specific requirements to the contrary, the nodes
# within the collection must be sorted in tree order.
class NodeList implements \ArrayAccess, \Countable, \Iterator {
use MagicProperties;
protected ?\Closure $filter = null;
protected int $_length = 0;
protected ?array $nodeArray = null;
protected int $position = 0;
protected function __get_length(): int {
# The length attribute must return the number of nodes represented by the
# collection.
return $this->count();
}
protected function __construct(array|\Closure $arrayOrClosure = []) {
// In this implementation the root part of the creation is handled either before
// the NodeList is created (array) or within the filter (\Closure).
if ($arrayOrClosure === null) {
$arrayOrClosure = [];
}
if (is_callable($arrayOrClosure)) {
$this->filter = $arrayOrClosure;
} else {
// Check types while also unpacking the iterable.
$array = [];
foreach ($arrayOrClosure as $i) {
if (!$i instanceof Node) {
$type = gettype($i);
if ($type === 'object') {
$type = get_class($i);
}
throw new Exception(Exception::ARGUMENT_TYPE_ERROR, 1, 'arrayOrClosure', 'array<Node>|\\Closure<array<Node>>', $type);
}
$array[] = $i;
}
$this->nodeArray = $array;
$this->_length = count($array);
}
}
public function count(): int {
if ($this->nodeArray !== null) {
return $this->_length;
}
$nodeArray = ($this->filter)();
return count($nodeArray);
}
public function current(): ?Node {
return $this->item($this->position);
}
public function item(int $index): ?Node {
# The item(index) method must return the indexth node in the collection. If
# there is no indexth node in the collection, then the method must return null.
if ($index >= $this->count()) {
return null;
}
$nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)();
if (array_key_exists($index, $nodeArray)) {
return $nodeArray[$index];
}
return null;
}
public function key(): int {
return $this->position;
}
public function next(): void {
$this->position++;
}
public function rewind(): void {
$this->position = 0;
}
public function offsetExists($offset): bool {
$nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)();
return array_key_exists($offset, $nodeArray);
}
public function offsetGet($offset): ?Node {
return $this->item($offset);
}
public function offsetSet($offset, $value): void {
// NodeLists are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function offsetUnset($offset): void {
// NodeLists are immutable; the spec is ambiguous as to what to do here.
// Browsers silently fail here, so that's what we're going to do.
}
public function valid() {
$this->offsetExists($this->position);
}
}
class NodeList extends Collection {}

52
tests/cases/TestDocument.php

@ -0,0 +1,52 @@
<?php
/**
* @license MIT
* Copyright 2017 Dustin Wilson, J. King, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\HTML\DOM\TestCase;
use MensBeam\HTML\DOM\{
Document,
Node,
XMLDocument
};
use MensBeam\HTML\Parser;
/** @covers \MensBeam\HTML\DOM\Document */
class TestDocument extends \PHPUnit\Framework\TestCase {
/**
* @covers \MensBeam\HTML\DOM\Document::__get_body
*
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Element::__construct
* @covers \MensBeam\HTML\DOM\Node::__construct
* @covers \MensBeam\HTML\DOM\Node::appendChild
* @covers \MensBeam\HTML\DOM\Node::preInsertionValidity
* @covers \MensBeam\HTML\DOM\InnerNode\Document::__construct
* @covers \MensBeam\HTML\DOM\InnerNode\Document::getWrapperNode
* @covers \MensBeam\HTML\DOM\InnerNode\Document::__get_wrapperNode
* @covers \MensBeam\HTML\DOM\InnerNode\Reflection::createFromProtectedConstructor
* @covers \MensBeam\HTML\DOM\InnerNode\Reflection::getProtectedProperty
* @covers \MensBeam\HTML\DOM\InnerNode\NodeMap::get
* @covers \MensBeam\HTML\DOM\InnerNode\NodeMap::has
* @covers \MensBeam\HTML\DOM\InnerNode\NodeMap::key
* @covers \MensBeam\HTML\DOM\InnerNode\NodeMap::set
*/
public function testProperty_body() {
$d = new Document();
$d->appendChild($d->createElement('html'));
// Node::body without body
$this->assertNull($d->body);
$body = $d->documentElement->appendChild($d->createElement('body'));
// Node::body with body
$this->assertSame($body, $d->body);
}
}

92
tests/cases/TestNode.php

@ -16,23 +16,35 @@ use MensBeam\HTML\DOM\{
use MensBeam\HTML\Parser;
/** @covers \MensBeam\HTML\DOM\Document */
/** @covers \MensBeam\HTML\DOM\Node */
class TestNode extends \PHPUnit\Framework\TestCase {
/**
* @covers \MensBeam\HTML\DOM\Node::cloneNode
*
* @covers \MensBeam\HTML\DOM\Attr::__construct
* @covers \MensBeam\HTML\DOM\Comment::__construct
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Collection::count
* @covers \MensBeam\HTML\DOM\Collection::current
* @covers \MensBeam\HTML\DOM\Collection::__get_length
* @covers \MensBeam\HTML\DOM\Collection::item
* @covers \MensBeam\HTML\DOM\Collection::key
* @covers \MensBeam\HTML\DOM\Collection::next
* @covers \MensBeam\HTML\DOM\NamedNodeMap::offsetGet
* @covers \MensBeam\HTML\DOM\Collection::offsetExists
* @covers \MensBeam\HTML\DOM\Collection::rewind
* @covers \MensBeam\HTML\DOM\Collection::valid
* @covers \MensBeam\HTML\DOM\Comment::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Document::createElementNS
* @covers \MensBeam\HTML\DOM\Document::createProcessingInstruction
* @covers \MensBeam\HTML\DOM\Document::createTextNode
* @covers \MensBeam\HTML\DOM\Document::importNode
* @covers \MensBeam\HTML\DOM\DocumentOrElement::validateAndExtract
* @covers \MensBeam\HTML\DOM\DocumentFragment::__construct
* @covers \MensBeam\HTML\DOM\DocumentType::__construct
@ -40,6 +52,9 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\DOMImplementation::__construct
* @covers \MensBeam\HTML\DOM\DOMImplementation::createDocumentType
* @covers \MensBeam\HTML\DOM\Element::__construct
* @covers \MensBeam\HTML\DOM\NamedNodeMap::__construct
* @covers \MensBeam\HTML\DOM\NamedNodeMap::current
* @covers \MensBeam\HTML\DOM\NamedNodeMap::item
* @covers \MensBeam\HTML\DOM\Node::__construct
* @covers \MensBeam\HTML\DOM\Node::isEqualNode
* @covers \MensBeam\HTML\DOM\ProcessingInstruction::__construct
@ -59,12 +74,15 @@ class TestNode extends \PHPUnit\Framework\TestCase {
$d = new Document();
$d2 = new XMLDocument();
$doctype = $d->appendChild($d->implementation->createDocumentType('html', '', ''));
$element = $d->appendChild($d->createElement('html'));
$element->appendChild($d->createElement('body'));
$d->body->setAttribute('id', 'ook');
$template = $d->body->appendChild($d->createElement('template'));
$template->content->appendChild($d->createTextNode('ook'));
$attr = $d->createAttribute('href');
$attr->value = 'https://poop💩.poop';
$cdata = $d2->createCDATASection('ook');
$comment = $d->createComment('comment');
$element = $d->createElement('html');
$element->appendChild($d->createElement('body'));
$pi = $d->createProcessingInstruction('ook', 'eek');
$text = $d->createTextNode('ook');
$frag = $d->createDocumentFragment();
@ -88,8 +106,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
// Node::cloneNode on document
$dClone = $d->cloneNode(true);
$this->assertNotSame($dClone, $d);
// Children on documents aren't cloned
$this->assertFalse($dClone->isEqualNode($d));
$this->assertTrue($dClone->isEqualNode($d));
// Node::cloneNode on doctype
$doctypeClone = $doctype->cloneNode();
@ -99,45 +116,35 @@ class TestNode extends \PHPUnit\Framework\TestCase {
// Node::cloneNode on document fragment
$fragClone = $frag->cloneNode(true);
$this->assertNotSame($fragClone, $frag);
// Children on document fragments aren't cloned
$this->assertFalse($fragClone->isEqualNode($frag));
$this->assertTrue($fragClone->isEqualNode($frag));
// Node::cloneNode on element
$elementClone = $element->cloneNode(true);
$this->assertNotSame($elementClone, $element);
// Children on documents aren't cloned
$this->assertTrue($elementClone->isEqualNode($element));
/*
// Node::nodeType on comment
$this->assertSame($d, $d->createComment('comment')->ownerDocument);
// Node::nodeType on document
$this->assertNull($d->ownerDocument);
// Node::nodeType on doctype
$this->assertSame($d, $d->implementation->createDocumentType('html', '', '')->ownerDocument);
// Node::cloneNode on processing instruction
$piClone = $pi->cloneNode();
$this->assertNotSame($piClone, $pi);
$this->assertTrue($piClone->isEqualNode($pi));
// Node::nodeType on document fragment
$this->assertSame($d, $d->createDocumentFragment()->ownerDocument);
// Node::nodeType on element
$this->assertSame($d, $d->createElement('html')->ownerDocument);
// Node::nodeType on processing instruction
$this->assertSame($d, $d->createProcessingInstruction('ook', 'eek')->ownerDocument);
// Node::nodeType on text node
$this->assertSame($d, $d->createTextNode('ook')->ownerDocument);
*/
// Node::cloneNode on text node
$textClone = $text->cloneNode();
$this->assertNotSame($textClone, $text);
$this->assertTrue($textClone->isEqualNode($text));
}
/**
* @covers \MensBeam\HTML\DOM\Node::__get_childNodes
*
* @covers \MensBeam\HTML\DOM\Collection::__construct
* @covers \MensBeam\HTML\DOM\Collection::count
* @covers \MensBeam\HTML\DOM\Collection::__get_length
* @covers \MensBeam\HTML\DOM\Collection::item
* @covers \MensBeam\HTML\DOM\Collection::offsetGet
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Document::createTextNode
* @covers \MensBeam\HTML\DOM\DOMImplementation::__construct
@ -145,11 +152,6 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Node::__construct
* @covers \MensBeam\HTML\DOM\Node::appendChild
* @covers \MensBeam\HTML\DOM\Node::preInsertionValidity
* @covers \MensBeam\HTML\DOM\NodeList::__construct
* @covers \MensBeam\HTML\DOM\NodeList::count
* @covers \MensBeam\HTML\DOM\NodeList::__get_length
* @covers \MensBeam\HTML\DOM\NodeList::item
* @covers \MensBeam\HTML\DOM\NodeList::offsetGet
* @covers \MensBeam\HTML\DOM\Text::__construct
* @covers \MensBeam\HTML\DOM\InnerNode\Document::__construct
* @covers \MensBeam\HTML\DOM\InnerNode\Document::getWrapperNode
@ -186,6 +188,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Node::__get_firstChild
*
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\DOMImplementation::__construct
* @covers \MensBeam\HTML\DOM\Element::__construct
@ -253,6 +256,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Node::__get_lastChild
*
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Document::createTextNode
* @covers \MensBeam\HTML\DOM\DOMImplementation::__construct
@ -295,6 +299,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Node::__get_previousSibling
*
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Document::createTextNode
* @covers \MensBeam\HTML\DOM\DocumentType::__construct
@ -339,6 +344,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Node::__get_nextSibling
*
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createElement
* @covers \MensBeam\HTML\DOM\Document::createTextNode
* @covers \MensBeam\HTML\DOM\DocumentType::__construct
@ -387,6 +393,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
@ -470,7 +477,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement
@ -531,7 +538,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement
@ -619,7 +626,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement
@ -680,8 +687,9 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\Comment::__construct
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::__get_body
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement
@ -758,7 +766,7 @@ class TestNode extends \PHPUnit\Framework\TestCase {
* @covers \MensBeam\HTML\DOM\CDATASection::__construct
* @covers \MensBeam\HTML\DOM\Document::__construct
* @covers \MensBeam\HTML\DOM\Document::createAttribute
* @covers \MensBeam\HTML\DOM\Document::createAttributeNS
* @covers \MensBeam\HTML\DOM\Document::__createAttribute
* @covers \MensBeam\HTML\DOM\Document::createComment
* @covers \MensBeam\HTML\DOM\Document::createDocumentFragment
* @covers \MensBeam\HTML\DOM\Document::createElement

1
tests/phpunit.dist.xml

@ -16,6 +16,7 @@
</coverage>
<testsuites>
<testsuite name="DOM">
<file>cases/TestDocument.php</file>
<file>cases/TestNode.php</file>
</testsuite>
</testsuites>

Loading…
Cancel
Save