View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.html.parser.neko;
16  
17  import static org.htmlunit.BrowserVersionFeatures.JS_SCRIPT_IN_TEMPLATE_EXECUTED_ON_ATTACH;
18  
19  import java.io.IOException;
20  import java.io.StringReader;
21  import java.net.URL;
22  import java.nio.charset.Charset;
23  import java.util.ArrayDeque;
24  import java.util.Deque;
25  
26  import org.htmlunit.BrowserVersion;
27  import org.htmlunit.ObjectInstantiationException;
28  import org.htmlunit.WebClient;
29  import org.htmlunit.WebResponse;
30  import org.htmlunit.cyberneko.HTMLConfiguration;
31  import org.htmlunit.cyberneko.HTMLElements;
32  import org.htmlunit.cyberneko.HTMLScanner;
33  import org.htmlunit.cyberneko.HTMLTagBalancingListener;
34  import org.htmlunit.cyberneko.xerces.parsers.AbstractSAXParser;
35  import org.htmlunit.cyberneko.xerces.xni.Augmentations;
36  import org.htmlunit.cyberneko.xerces.xni.QName;
37  import org.htmlunit.cyberneko.xerces.xni.XMLAttributes;
38  import org.htmlunit.cyberneko.xerces.xni.XMLString;
39  import org.htmlunit.cyberneko.xerces.xni.XNIException;
40  import org.htmlunit.cyberneko.xerces.xni.parser.XMLInputSource;
41  import org.htmlunit.cyberneko.xerces.xni.parser.XMLParserConfiguration;
42  import org.htmlunit.html.DomCDataSection;
43  import org.htmlunit.html.DomComment;
44  import org.htmlunit.html.DomDocumentType;
45  import org.htmlunit.html.DomElement;
46  import org.htmlunit.html.DomNode;
47  import org.htmlunit.html.DomText;
48  import org.htmlunit.html.ElementFactory;
49  import org.htmlunit.html.Html;
50  import org.htmlunit.html.HtmlBody;
51  import org.htmlunit.html.HtmlElement;
52  import org.htmlunit.html.HtmlForm;
53  import org.htmlunit.html.HtmlHiddenInput;
54  import org.htmlunit.html.HtmlImage;
55  import org.htmlunit.html.HtmlPage;
56  import org.htmlunit.html.HtmlSvg;
57  import org.htmlunit.html.HtmlTable;
58  import org.htmlunit.html.HtmlTableRow;
59  import org.htmlunit.html.HtmlTemplate;
60  import org.htmlunit.html.ScriptElement;
61  import org.htmlunit.html.SubmittableElement;
62  import org.htmlunit.html.UnknownElementFactory;
63  import org.htmlunit.html.XHtmlPage;
64  import org.htmlunit.html.parser.HTMLParser;
65  import org.htmlunit.html.parser.HTMLParserDOMBuilder;
66  import org.htmlunit.html.parser.HTMLParserListener;
67  import org.htmlunit.javascript.host.html.HTMLBodyElement;
68  import org.htmlunit.svg.SvgElementFactory;
69  import org.htmlunit.util.StringUtils;
70  import org.w3c.dom.Node;
71  import org.xml.sax.Attributes;
72  import org.xml.sax.ContentHandler;
73  import org.xml.sax.Locator;
74  import org.xml.sax.SAXException;
75  import org.xml.sax.ext.LexicalHandler;
76  
77  /**
78   * <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
79   *
80   * The parser and DOM builder. This class subclasses Xerces's AbstractSAXParser and implements
81   * the ContentHandler interface. Thus all parser APIs are kept private. The ContentHandler methods
82   * consume SAX events to build the page DOM
83   *
84   * @author Christian Sell
85   * @author David K. Taylor
86   * @author Chris Erskine
87   * @author Ahmed Ashour
88   * @author Marc Guillemot
89   * @author Ethan Glasser-Camp
90   * @author Sudhan Moghe
91   * @author Ronald Brill
92   * @author Frank Danek
93   * @author Carsten Steul
94   * @author Ronny Shapiro
95   * @author Atsushi Nakagawa
96   */
97  final class HtmlUnitNekoDOMBuilder extends AbstractSAXParser
98          implements ContentHandler, LexicalHandler, HTMLTagBalancingListener, HTMLParserDOMBuilder {
99  
100     // cache Neko Elements for performance and memory efficiency
101     private static final HTMLElements HTMLELEMENTS = new HTMLElements();
102 
103     private enum HeadParsed { YES, SYNTHESIZED, NO }
104 
105     private final HTMLParser htmlParser_;
106     private final HtmlPage page_;
107 
108     private Locator locator_;
109     private final Deque<DomNode> stack_ = new ArrayDeque<>();
110 
111     /** Did the snippet tried to overwrite the start node. */
112     private boolean snippetStartNodeOverwritten_;
113     private final int initialSize_;
114     private DomNode currentNode_;
115     private final boolean createdByJavascript_;
116     private final XMLString characters_ = new XMLString();
117     private HtmlUnitNekoDOMBuilder.HeadParsed headParsed_ = HeadParsed.NO;
118     private HtmlElement body_;
119     private boolean lastTagWasSynthesized_;
120     private HtmlForm consumingForm_;
121     private boolean formEndingIsAdjusting_;
122     private boolean insideSvg_;
123     private boolean insideTemplate_;
124 
125     private static final String FEATURE_AUGMENTATIONS = "http://cyberneko.org/html/features/augmentations";
126     private static final String FEATURE_PARSE_NOSCRIPT
127         = "http://cyberneko.org/html/features/parse-noscript-content";
128 
129     /**
130      * Parses and then inserts the specified HTML content into the HTML content currently being parsed.
131      * @param html the HTML content to push
132      */
133     @Override
134     public void pushInputString(final String html) {
135         page_.registerParsingStart();
136         page_.registerInlineSnippetParsingStart();
137         try {
138             final WebResponse webResponse = page_.getWebResponse();
139             final Charset charset = webResponse.getContentCharset();
140             final String url = webResponse.getWebRequest().getUrl().toString();
141             final XMLInputSource in = new XMLInputSource(null, url, null, new StringReader(html), charset.name());
142             ((HTMLConfiguration) parserConfiguration_).evaluateInputSource(in);
143         }
144         finally {
145             page_.registerParsingEnd();
146             page_.registerInlineSnippetParsingEnd();
147         }
148     }
149 
150     /**
151      * Creates a new builder for parsing the specified response contents.
152      * @param node the location at which to insert the new content
153      * @param url the page's URL
154      * @param createdByJavascript if true the (script) tag was created by javascript
155      */
156     HtmlUnitNekoDOMBuilder(final HTMLParser htmlParser, final WebClient webClient,
157             final DomNode node, final URL url, final String htmlContent, final boolean createdByJavascript) {
158         super(createConfiguration(webClient.getBrowserVersion()));
159 
160         htmlParser_ = htmlParser;
161         page_ = (HtmlPage) node.getPage();
162 
163         currentNode_ = node;
164         for (final Node ancestor : currentNode_.getAncestors()) {
165             stack_.push((DomNode) ancestor);
166         }
167         createdByJavascript_ = createdByJavascript;
168 
169         final HTMLParserListener listener = webClient.getHTMLParserListener();
170         final boolean reportErrors = listener != null;
171         if (reportErrors) {
172             parserConfiguration_.setErrorHandler(new HtmlUnitNekoHTMLErrorHandler(listener, url, htmlContent));
173         }
174 
175         try {
176             setFeature(FEATURE_AUGMENTATIONS, true);
177             setFeature("http://cyberneko.org/html/features/report-errors", reportErrors);
178             setFeature(FEATURE_PARSE_NOSCRIPT, !webClient.isJavaScriptEnabled());
179             setFeature(HTMLScanner.ALLOW_SELFCLOSING_IFRAME, false);
180 
181             setContentHandler(this);
182             setLexicalHandler(this); //comments and CDATA
183         }
184         catch (final SAXException e) {
185             throw new ObjectInstantiationException("unable to create HTML parser", e);
186         }
187         initialSize_ = stack_.size();
188     }
189 
190     /**
191      * Sets the svg state; used for parsing fragments where the context
192      * (e.g. svg.innerHTML) is svg content.
193      *
194      * @param insideSvg true if the parsed content is svg content
195      */
196     void setInsideSvg(final boolean insideSvg) {
197         insideSvg_ = insideSvg;
198     }
199 
200     /**
201      * Create the configuration depending on the simulated browser.
202      * @return the configuration
203      */
204     private static XMLParserConfiguration createConfiguration(final BrowserVersion browserVersion) {
205         // HTMLElements.HTMLElementsWithCache are not thread safe
206         // because the cache is not synchronized
207         // we have to create a new one for each parser run
208         return new HTMLConfiguration(new HTMLElements.HTMLElementsWithCache(HTMLELEMENTS));
209     }
210 
211     /**
212      * {@inheritDoc}
213      */
214     @Override
215     public void setDocumentLocator(final Locator locator) {
216         locator_ = locator;
217     }
218 
219     /**
220      * {@inheritDoc}
221      */
222     @Override
223     public void startDocument() throws SAXException {
224         // nothing to do
225     }
226 
227     /** {@inheritDoc} */
228     @Override
229     public void startElement(final QName element, final XMLAttributes attributes, final Augmentations augs)
230         throws XNIException {
231         // augs might change so we store only the interesting part
232         lastTagWasSynthesized_ = augs.isSynthesized();
233         super.startElement(element, attributes, augs);
234     }
235 
236     /**
237      * {@inheritDoc}
238      */
239     @Override
240     public void startElement(String namespaceURI, final String localName, final String qName, final Attributes atts)
241         throws SAXException {
242 
243         if (snippetStartNodeOverwritten_) {
244             snippetStartNodeOverwritten_ = false;
245             return;
246         }
247         handleCharacters();
248 
249         final String tagLower = StringUtils.toRootLowerCase(localName);
250         if (page_.isParsingHtmlSnippet() && ("html".equals(tagLower) || "body".equals(tagLower))) {
251             // we have to push the current node on the stack to make sure
252             // the endElement call is able to remove a node from the stack
253             stack_.push(currentNode_);
254             return;
255         }
256 
257         if ("head".equals(tagLower)) {
258             if (headParsed_ == HeadParsed.YES || page_.isParsingHtmlSnippet()) {
259                 // we have to push the current node on the stack to make sure
260                 // the endElement call is able to remove a node from the stack
261                 stack_.push(currentNode_);
262                 return;
263             }
264 
265             headParsed_ = lastTagWasSynthesized_ ? HeadParsed.SYNTHESIZED : HeadParsed.YES;
266         }
267 
268         // If we're adding a body element, keep track of any temporary synthetic ones
269         // that we may have had to create earlier (for document.write(), for example).
270         HtmlBody oldBody = null;
271         final boolean isBodyTag = "body".equals(tagLower);
272         if (isBodyTag) {
273             final HtmlBody body = page_.getBody();
274             if (body != null) {
275                 oldBody = body;
276             }
277         }
278 
279         if (namespaceURI != null) {
280             namespaceURI = namespaceURI.trim();
281         }
282         // Add the new node.
283         if (!(page_ instanceof XHtmlPage) && Html.XHTML_NAMESPACE.equals(namespaceURI)) {
284             namespaceURI = null;
285         }
286 
287         ElementFactory factory =
288                 htmlParser_.getElementFactory(page_, namespaceURI, qName, insideSvg_, false);
289 
290         // in xhtml (xml) the namespace decides; an svg element without the svg namespace
291         // (e.g. inheriting the xhtml namespace) is an unknown html element
292         if (page_ instanceof XHtmlPage
293                 && !insideSvg_
294                 && "svg".equals(tagLower)
295                 && !Html.SVG_NAMESPACE.equals(namespaceURI)
296                 && !Html.SVG_NAMESPACE.equals(atts.getValue("xmlns"))) {
297             factory = UnknownElementFactory.INSTANCE;
298         }
299 
300         String elementName = qName;
301         if (factory == HtmlUnitNekoHtmlParser.SVG_FACTORY) {
302             namespaceURI = Html.SVG_NAMESPACE;
303 
304             // the html parser lowercases the name and restores the case of some svg names
305             // (e.g. 'foreignObject'); xml (xhtml) is case sensitive
306             if (!(page_ instanceof XHtmlPage)) {
307                 elementName = SvgElementFactory.adjustTagNameForHtmlParser(qName);
308             }
309         }
310 
311         final DomElement newElement = factory.createElementNS(page_, namespaceURI, elementName, atts);
312         newElement.setStartLocation(locator_.getLineNumber(), locator_.getColumnNumber());
313 
314         // parse can't replace everything as it does not buffer elements while parsing
315         addNodeToRightParent(currentNode_, newElement);
316 
317         if (newElement instanceof HtmlSvg) {
318             insideSvg_ = true;
319         }
320         else if (newElement instanceof HtmlTemplate) {
321             insideTemplate_ = true;
322         }
323 
324         // Forms own elements simply by enclosing source-wise rather than DOM parent-child relationship
325         // Forms without a </form> will keep consuming forever
326         else if (newElement instanceof HtmlForm form) {
327             consumingForm_ = form;
328             formEndingIsAdjusting_ = false;
329         }
330         else if (consumingForm_ != null) {
331             // If the current form enclosed a suitable element
332             if (newElement instanceof SubmittableElement) {
333                 // Let these be owned by the form
334                 if (((HtmlElement) newElement).getEnclosingForm() != consumingForm_) {
335                     ((HtmlElement) newElement).setOwningForm(consumingForm_);
336                 }
337             }
338         }
339 
340         // If we had an old synthetic body, and we just added a real body element, quietly
341         // remove the old body and move its children to the real body element we just added.
342         if (oldBody != null) {
343             oldBody.quietlyRemoveAndMoveChildrenTo(newElement);
344         }
345 
346         if (!insideSvg_ && isBodyTag) {
347             body_ = (HtmlElement) newElement;
348         }
349         else if (createdByJavascript_
350                 && newElement instanceof ScriptElement script
351                 && (!insideTemplate_
352                         || !page_.getWebClient().getBrowserVersion()
353                                 .hasFeature(JS_SCRIPT_IN_TEMPLATE_EXECUTED_ON_ATTACH))) {
354             script.markAsCreatedByDomParser();
355         }
356 
357         currentNode_ = newElement;
358         stack_.push(currentNode_);
359     }
360 
361     /**
362      * Adds the new node to the right parent that is not necessary the currentNode in case of
363      * malformed HTML code. The method tries to emulate the behavior of Firefox.
364      */
365     private void addNodeToRightParent(final DomNode currentNode, final DomElement newElement) {
366         final String currentNodeName = currentNode.getNodeName();
367         final String newNodeName = newElement.getNodeName();
368 
369         // First ensure table elements are housed correctly
370         if (isTableChild(newNodeName)) {
371             final DomNode parent =
372                     "table".equals(currentNodeName) ? currentNode : findElementOnStack("table");
373             appendChild(parent, newElement);
374             return;
375         }
376         if ("tr".equals(newNodeName)) {
377             final DomNode parent =
378                     isTableChild(currentNodeName) ? currentNode : findElementOnStack("tbody", "thead", "tfoot");
379             appendChild(parent, newElement);
380             return;
381         }
382         if (isTableCell(newNodeName)) {
383             final DomNode parent =
384                     "tr".equals(currentNodeName) ? currentNode : findElementOnStack("tr");
385             appendChild(parent, newElement);
386             return;
387         }
388 
389         // Next ensure non-table elements don't appear in tables
390         if ("table".equals(currentNodeName) || isTableChild(currentNodeName) || "tr".equals(currentNodeName)) {
391             if ("template".equals(newNodeName)) {
392                 currentNode.appendChild(newElement);
393             }
394 
395             // Scripts, forms, and styles are exempt
396             else if (!"colgroup".equals(currentNodeName)
397                     && ("script".equals(newNodeName)
398                         || "form".equals(newNodeName)
399                         || "style".equals(newNodeName))) {
400                 currentNode.appendChild(newElement);
401             }
402 
403             // These are good
404             else if ("col".equals(newNodeName) && "colgroup".equals(currentNodeName)) {
405                 currentNode.appendChild(newElement);
406             }
407             else if ("caption".equals(currentNodeName)) {
408                 currentNode.appendChild(newElement);
409             }
410             else if (newElement instanceof HtmlHiddenInput) {
411                 currentNode.appendChild(newElement);
412             }
413             else {
414                 // Move before the table
415                 final DomNode parent = findElementOnStack("table");
416                 parent.insertBefore(newElement);
417             }
418             return;
419         }
420 
421         if (formEndingIsAdjusting_ && "form".equals(currentNodeName)) {
422             // We cater to HTMLTagBalancer's shortcomings by moving this node out of the <form>
423             appendChild(currentNode.getParentNode(), newElement);
424             return;
425         }
426 
427         // Everything else
428         appendChild(currentNode, newElement);
429     }
430 
431     private DomNode findElementOnStack(final String searchedElementName) {
432         for (final DomNode node : stack_) {
433             if (searchedElementName.equals(node.getNodeName())) {
434                 return node;
435             }
436         }
437 
438         // this is surely wrong but at least it won't throw a NPE
439         return stack_.peek();
440     }
441 
442     private DomNode findElementOnStack(final String... searchedElementNames) {
443         for (final DomNode node : stack_) {
444             for (final String searchedElementName : searchedElementNames) {
445                 if (searchedElementName.equals(node.getNodeName())) {
446                     return node;
447                 }
448             }
449         }
450 
451         // this is surely wrong but at least it won't throw a NPE
452         return stack_.peek();
453     }
454 
455     private static boolean isTableChild(final String nodeName) {
456         if (nodeName == null || nodeName.length() < 5) {
457             return false;
458         }
459 
460         return "thead".equals(nodeName)
461                 || "tbody".equals(nodeName)
462                 || "tfoot".equals(nodeName)
463                 || "caption".equals(nodeName)
464                 || "colgroup".equals(nodeName);
465     }
466 
467     private static boolean isTableCell(final String nodeName) {
468         if (nodeName == null || nodeName.length() != 2) {
469             return false;
470         }
471         return "td".equals(nodeName) || "th".equals(nodeName);
472     }
473 
474     /** {@inheritDoc} */
475     @Override
476     public void endElement(final QName element, final Augmentations augs)
477         throws XNIException {
478         // augs might change so we store only the interesting part
479         lastTagWasSynthesized_ = augs.isSynthesized();
480         super.endElement(element, augs);
481     }
482 
483     /**
484      * {@inheritDoc}
485      */
486     @Override
487     public void endElement(final String namespaceURI, final String localName, final String qName)
488         throws SAXException {
489 
490         final String tagLower = StringUtils.toRootLowerCase(localName);
491 
492         handleCharacters();
493 
494         if (page_.isParsingHtmlSnippet()) {
495             if ("html".equals(tagLower) || "body".equals(tagLower)) {
496                 return;
497             }
498             if (stack_.size() == initialSize_) {
499                 // a <p> inside a <p> is valid for innerHTML processing
500                 // see HTMLParser2Test for more cases
501                 snippetStartNodeOverwritten_ = !StringUtils.equalsChar('p', tagLower);
502                 return;
503             }
504         }
505 
506         if ("svg".equals(tagLower)) {
507             insideSvg_ = false;
508         }
509         else if ("template".equals(tagLower)) {
510             insideTemplate_ = false;
511         }
512 
513         // this only avoids a problem when the stack is empty here
514         // but for this case we made the problem before - the balancing
515         // is broken already
516         if (stack_.isEmpty()) {
517             return;
518         }
519 
520         final DomNode previousNode = stack_.pop(); //remove currentElement from stack
521         previousNode.setEndLocation(locator_.getLineNumber(), locator_.getColumnNumber());
522 
523         if ("form".equals(tagLower) && !lastTagWasSynthesized_) {
524             // We get here if the </form> was on the same DOM tree depth as the <form> that started it,
525             // otherwise HTMLTagBalancer gives us the end through ignoredEndElement()
526             consumingForm_ = null;
527         }
528 
529         if (!stack_.isEmpty()) {
530             currentNode_ = stack_.peek();
531         }
532 
533         final boolean postponed = page_.isParsingInlineHtmlSnippet();
534         previousNode.onAllChildrenAddedToPage(postponed);
535     }
536 
537     /** {@inheritDoc} */
538     @Override
539     public void characters(final char[] ch, final int start, final int length) throws SAXException {
540         characters_.append(ch, start, length);
541     }
542 
543     /** {@inheritDoc} */
544     @Override
545     public void ignorableWhitespace(final char[] ch, final int start, final int length) throws SAXException {
546         characters_.append(ch, start, length);
547     }
548 
549     /**
550      * Picks up the character data accumulated so far and add it to the current element as a text node.
551      */
552     private void handleCharacters() {
553         // make the code easier to read because we remove a nesting level
554         if (characters_.length() == 0) {
555             return;
556         }
557 
558         // Use the normal behavior: append a text node for the accumulated text.
559         final String textValue = characters_.toString();
560         characters_.clear();
561 
562         if (StringUtils.isBlank(textValue)) {
563             appendChild(currentNode_, new DomText(page_, textValue));
564             return;
565         }
566 
567         // malformed HTML: </td>some text</tr> => text comes before the table
568         if (currentNode_ instanceof HtmlTableRow row) {
569             final HtmlTable enclosingTable = row.getEnclosingTable();
570             if (enclosingTable != null) { // may be null when called from Range.createContextualFragment
571                 if (enclosingTable.getPreviousSibling() instanceof DomText domText) {
572                     domText.setTextContent(domText.getWholeText() + textValue);
573                 }
574                 else {
575                     enclosingTable.insertBefore(new DomText(page_, textValue));
576                 }
577             }
578         }
579         else if (currentNode_ instanceof HtmlTable enclosingTable) {
580             if (enclosingTable.getPreviousSibling() instanceof DomText domText) {
581                 domText.setTextContent(domText.getWholeText() + textValue);
582             }
583             else {
584                 enclosingTable.insertBefore(new DomText(page_, textValue));
585             }
586         }
587         else if (currentNode_ instanceof HtmlImage) {
588             currentNode_.getParentNode().appendChild(new DomText(page_, textValue));
589         }
590         else {
591             appendChild(currentNode_, new DomText(page_, textValue));
592         }
593     }
594 
595     /** {@inheritDoc} */
596     @Override
597     public void endDocument() throws SAXException {
598         handleCharacters();
599         if (locator_ != null) {
600             page_.setEndLocation(locator_.getLineNumber(), locator_.getColumnNumber());
601         }
602     }
603 
604     /** {@inheritDoc} */
605     @Override
606     public void startPrefixMapping(final String prefix, final String uri) throws SAXException {
607         // nothing to do
608     }
609 
610     /** {@inheritDoc} */
611     @Override
612     public void endPrefixMapping(final String prefix) throws SAXException {
613         // nothing to do
614     }
615 
616     /** {@inheritDoc} */
617     @Override
618     public void processingInstruction(final String target, final String data) throws SAXException {
619         // nothing to do
620     }
621 
622     /** {@inheritDoc} */
623     @Override
624     public void skippedEntity(final String name) throws SAXException {
625         // nothing to do
626     }
627 
628     // LexicalHandler methods
629 
630     /** {@inheritDoc} */
631     @Override
632     public void comment(final char[] ch, final int start, final int length) {
633         handleCharacters();
634         final String data = new String(ch, start, length);
635         final DomComment comment = new DomComment(page_, data);
636         appendChild(currentNode_, comment);
637     }
638 
639     /** {@inheritDoc} */
640     @Override
641     public void endCDATA() {
642         final String data = characters_.toString();
643         characters_.clear();
644 
645         final DomCDataSection cdataSection = new DomCDataSection(page_, data);
646         appendChild(currentNode_, cdataSection);
647     }
648 
649     /** {@inheritDoc} */
650     @Override
651     public void endDTD() {
652         // nothing to do
653     }
654 
655     /** {@inheritDoc} */
656     @Override
657     public void endEntity(final String name) {
658         // nothing to do
659     }
660 
661     /** {@inheritDoc} */
662     @Override
663     public void startCDATA() {
664         handleCharacters();
665     }
666 
667     /** {@inheritDoc} */
668     @Override
669     public void startDTD(final String name, final String publicId, final String systemId) {
670         final DomDocumentType type = new DomDocumentType(page_, name, publicId, systemId);
671         page_.setDocumentType(type);
672 
673         final Node child;
674         child = type;
675         page_.appendChild(child);
676     }
677 
678     /** {@inheritDoc} */
679     @Override
680     public void startEntity(final String name) {
681         // nothing to do
682     }
683 
684     /**
685      * {@inheritDoc}
686      */
687     @Override
688     public void ignoredEndElement(final QName element, final Augmentations augs) {
689         // HTMLTagBalancer brings us here if </form> was found in the source on a different
690         // DOM tree depth (either above or below) to the <form> that started it
691         if ("form".equals(element.getLocalpart()) && consumingForm_ != null) {
692             consumingForm_ = null;
693 
694             if (findElementOnStack("table", "form") instanceof HtmlTable) {
695                 // The </form> just goes missing for these (really? just tables?)
696             }
697             else {
698                 /*
699                  * This </form> was ignored by HTMLTagBalancer as it generates its own
700                  * </form> at the end of the depth with the starting <form>.
701                  * e.g. This:
702                  * | <form>
703                  * |   <div>
704                  * |     </form> <!--ignored by HTMLTagBalancer-->
705                  * |   </div>
706                  * |   <input>
707                  *
708                  * is turned into:
709                  * | <form>
710                  * |   <div>
711                  * |   </div>
712                  * |   <input>
713                  * | </form> <!--synthesized by HTMLTagBalancer-->
714                  *
715                  * but this isn't suitable for us because </form> shouldn't be ignored but
716                  * rather moved directly behind the tree it's in to instead become:
717                  * | <form>
718                  * |   <div>
719                  * |   </div>
720                  * | </form> <!--moved out of div-->
721                  * | <input> <!--proceeding children are not part of form-->
722                  */
723                 // We cater for this by moving out nodes such as the <input> in the above
724                 // diagram out of the form
725                 formEndingIsAdjusting_ = true;
726             }
727         }
728     }
729 
730     /**
731      * {@inheritDoc}
732      */
733     @Override
734     public void ignoredStartElement(final QName elem, final XMLAttributes attrs, final Augmentations augs) {
735         // when multiple html/body elements are encountered, the attributes of the discarded
736         // elements are used when not previously defined
737         if (attrs != null && body_ != null) {
738             final String lp = elem.getLocalpart();
739             if (lp != null && lp.length() == 4) {
740                 if ("body".equalsIgnoreCase(lp)) {
741                     copyAttributes(body_, attrs);
742                 }
743                 else if ("html".equalsIgnoreCase(lp)) {
744                     final DomNode parent = body_.getParentNode();
745                     if (parent instanceof DomElement element) {
746                         copyAttributes(element, attrs);
747                     }
748                 }
749             }
750         }
751     }
752 
753     private static void copyAttributes(final DomElement to, final XMLAttributes attrs) {
754         final int length = attrs.getLength();
755 
756         for (int i = 0; i < length; i++) {
757             final String attrName = StringUtils.toRootLowerCase(attrs.getLocalName(i));
758             if (to.getAttributes().getNamedItem(attrName) == null) {
759                 to.setAttribute(attrName, attrs.getValue(i));
760                 if (attrName.startsWith("on") && to.getPage().getWebClient().isJavaScriptEngineEnabled()
761                         && to.getScriptableObject() instanceof HTMLBodyElement) {
762                     final HTMLBodyElement jsBody = to.getScriptableObject();
763                     jsBody.createEventHandlerFromAttribute(attrName, attrs.getValue(i));
764                 }
765             }
766         }
767     }
768 
769     /**
770      * {@inheritDoc}
771      */
772     @Override
773     public void parse(final XMLInputSource inputSource) throws XNIException, IOException {
774         final HTMLParserDOMBuilder oldBuilder = page_.getDOMBuilder();
775         page_.setDOMBuilder(this);
776         try {
777             super.parse(inputSource);
778         }
779         finally {
780             page_.setDOMBuilder(oldBuilder);
781         }
782     }
783 
784     private static void appendChild(final DomNode parent, final DomNode child) {
785         if (parent instanceof HtmlTemplate template) {
786             template.getContent().appendChild(child);
787             return;
788         }
789 
790         parent.appendChild(child);
791     }
792 }