/* GNU LESSER GENERAL PUBLIC LICENSE Copyright (C) 2006 The Lobo Project This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA Contact info: lobochief@users.sourceforge.net */ /* * Created on Sep 3, 2005 */ package org.lobobrowser.html.domimpl; import java.io.IOException; import java.io.LineNumberReader; import java.io.Reader; import java.io.StringReader; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import org.eclipse.jdt.annotation.NonNull; import org.lobobrowser.html.HtmlRendererContext; import org.lobobrowser.html.domimpl.NodeFilter.AnchorFilter; import org.lobobrowser.html.domimpl.NodeFilter.AppletFilter; import org.lobobrowser.html.domimpl.NodeFilter.ElementFilter; import org.lobobrowser.html.domimpl.NodeFilter.ElementNameFilter; import org.lobobrowser.html.domimpl.NodeFilter.FormFilter; import org.lobobrowser.html.domimpl.NodeFilter.FrameFilter; import org.lobobrowser.html.domimpl.NodeFilter.ImageFilter; import org.lobobrowser.html.domimpl.NodeFilter.LinkFilter; import org.lobobrowser.html.domimpl.NodeFilter.TagNameFilter; import org.lobobrowser.html.io.WritableLineReader; import org.lobobrowser.html.js.Event; import org.lobobrowser.html.js.EventTargetManager; import org.lobobrowser.html.js.Location; import org.lobobrowser.html.js.Window; import org.lobobrowser.html.js.Window.JSRunnableTask; import org.lobobrowser.html.parser.HtmlParser; import org.lobobrowser.html.style.CSSNorm; import org.lobobrowser.html.style.RenderState; import org.lobobrowser.html.style.StyleElements; import org.lobobrowser.html.style.StyleSheetRenderState; import org.lobobrowser.js.HideFromJS; import org.lobobrowser.request.DomainValidation; import org.lobobrowser.ua.ImageResponse; import org.lobobrowser.ua.NetworkRequest; import org.lobobrowser.ua.UserAgentContext; import org.lobobrowser.ua.UserAgentContext.Request; import org.lobobrowser.ua.UserAgentContext.RequestKind; import org.lobobrowser.util.SecurityUtil; import org.lobobrowser.util.Urls; import org.lobobrowser.util.WeakValueHashMap; import org.lobobrowser.util.io.EmptyReader; import org.mozilla.javascript.Function; import org.w3c.dom.Attr; import org.w3c.dom.CDATASection; import org.w3c.dom.Comment; import org.w3c.dom.DOMConfiguration; import org.w3c.dom.DOMException; import org.w3c.dom.DOMImplementation; import org.w3c.dom.DocumentFragment; import org.w3c.dom.DocumentType; import org.w3c.dom.Element; import org.w3c.dom.EntityReference; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.ProcessingInstruction; import org.w3c.dom.Text; import org.w3c.dom.UserDataHandler; import org.w3c.dom.css.CSSStyleSheet; import org.w3c.dom.events.EventException; import org.w3c.dom.events.EventListener; import org.w3c.dom.events.EventTarget; import org.w3c.dom.html.HTMLCollection; import org.w3c.dom.html.HTMLDocument; import org.w3c.dom.html.HTMLElement; import org.w3c.dom.ranges.Range; import org.w3c.dom.stylesheets.DocumentStyle; import org.w3c.dom.stylesheets.LinkStyle; import org.w3c.dom.stylesheets.StyleSheetList; import org.w3c.dom.views.AbstractView; import org.w3c.dom.views.DocumentView; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXException; import co.uproot.css.domimpl.JStyleSheetWrapper; import co.uproot.css.domimpl.StyleSheetBridge; import cz.vutbr.web.css.CSSException; import cz.vutbr.web.css.ElementMatcher; import cz.vutbr.web.css.MediaSpec; import cz.vutbr.web.css.StyleSheet; import cz.vutbr.web.csskit.ElementMatcherSafeCS; import cz.vutbr.web.csskit.ElementMatcherSafeStd; import cz.vutbr.web.csskit.antlr4.CSSParserFactory; import cz.vutbr.web.csskit.antlr4.CSSParserFactory.SourceType; import cz.vutbr.web.domassign.Analyzer.Holder; import cz.vutbr.web.domassign.AnalyzerUtil; /** * Implementation of the W3C <code>HTMLDocument</code> interface. */ public class HTMLDocumentImpl extends NodeImpl implements HTMLDocument, DocumentView, DocumentStyle, EventTarget { private final ElementFactory factory; private final HtmlRendererContext rcontext; private final UserAgentContext ucontext; private final Window window; private final Map<String, Element> elementsById = new WeakValueHashMap<>(); private String documentURI; private java.net.URL documentURL; protected final StyleSheetManager styleSheetManager = new StyleSheetManager(); private final String contentType; private WritableLineReader reader; public HTMLDocumentImpl(final HtmlRendererContext rcontext) { this(rcontext.getUserAgentContext(), rcontext, null, null, null); } public HTMLDocumentImpl(final UserAgentContext ucontext) { this(ucontext, null, null, null, null); } public HTMLDocumentImpl(final UserAgentContext ucontext, final HtmlRendererContext rcontext, final WritableLineReader reader, final String documentURI, final String contentType) { this.factory = ElementFactory.getInstance(); this.rcontext = rcontext; this.ucontext = ucontext; this.reader = reader; this.documentURI = documentURI; this.contentType = contentType; try { final java.net.URL docURL = new java.net.URL(documentURI); final SecurityManager sm = System.getSecurityManager(); if (sm != null) { // Do not allow creation of HTMLDocumentImpl if there's // no permission to connect to the host of the URL. // This is so that cookies cannot be written arbitrarily // with setCookie() method. final String protocol = docURL.getProtocol(); if ("http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol)) { sm.checkPermission(new java.net.SocketPermission(docURL.getHost(), "connect")); } } this.documentURL = docURL; this.domain = docURL.getHost(); } catch (final java.net.MalformedURLException mfu) { logger.warning("HTMLDocumentImpl(): Document URI [" + documentURI + "] is malformed."); } // TODO: This should be inside the try block above. That is, if there is a malformed URL, the below shouldn't be allowed. // It is currently being allowed to quickly bootstrap and run web-platform-tests. // One failure case is: The methods in DOMImplemenationImpl call those constructors which have null document URIs. // Such constructors should be ideally removed. this.document = this; // Get Window object Window window; if (rcontext != null) { window = Window.getWindow(rcontext); } else { // Plain parsers may use Javascript too. window = new Window(null, ucontext); } // Window must be retained or it will be garbage collected. this.window = window; window.setDocument(this); } private Set<Locale> locales; /** * Gets an <i>immutable</i> set of locales previously set for this document. */ public Set<Locale> getLocales() { return locales; } /** * Sets the locales of the document. This helps determine whether specific * fonts can display text in the languages of all the locales. * * @param locales * An <i>immutable</i> set of <code>java.util.Locale</code> * instances. */ public void setLocales(final Set<Locale> locales) { this.locales = locales; } String getDocumentHost() { final URL docUrl = this.documentURL; return docUrl == null ? null : docUrl.getHost(); } @Override public URL getDocumentURL() { // TODO: Security considerations? return this.documentURL; } /** * Caller should synchronize on document. */ void setElementById(final String id, final Element element) { synchronized (this) { // TODO: Need to take care of document order. The following check is crude and only takes // care of document order for elements in static HTML. if (!elementsById.containsKey(id)) { this.elementsById.put(id, element); } } } void removeElementById(final String id) { synchronized (this) { this.elementsById.remove(id); } } private volatile String baseURI; /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#getbaseURI() */ @Override public String getBaseURI() { final String buri = this.baseURI; return buri == null ? this.documentURI : buri; } public void setBaseURI(final String value) { if (value != null) { try { @SuppressWarnings("unused") final URL ignore = new URL(value); // this is a full url if it parses this.baseURI = value; } catch (final MalformedURLException mfe) { try { Urls.createURL(documentURL, value); } catch (final MalformedURLException mfe2) { throw new IllegalArgumentException(mfe2); } } } else { this.baseURI = null; } } private String defaultTarget; public String getDefaultTarget() { return this.defaultTarget; } public void setDefaultTarget(final String value) { this.defaultTarget = value; } public AbstractView getDefaultView() { return this.window; } public Window getWindow() { return this.window; } @Override public String getTextContent() throws DOMException { return null; } @Override public void setTextContent(final String textContent) throws DOMException { // NOP, per spec } private String title; public String getTitle() { return this.title; } public void setTitle(final String title) { this.title = title; } private String referrer; public String getReferrer() { return this.referrer; } public void setReferrer(final String value) { this.referrer = value; } private String domain; public String getDomain() { return this.domain; } public void setDomain(final String domain) { final String oldDomain = this.domain; if ((oldDomain != null) && DomainValidation.isValidCookieDomain(domain, oldDomain)) { this.domain = domain; } else { throw new SecurityException("Cannot set domain to '" + domain + "' when current domain is '" + oldDomain + "'"); } } public HTMLElement getBody() { synchronized (this) { return this.body; } } private HTMLCollection images; private HTMLCollection applets; private HTMLCollection links; private HTMLCollection forms; private HTMLCollection anchors; private HTMLCollection frames; public HTMLCollection getImages() { synchronized (this) { if (this.images == null) { this.images = new DescendentHTMLCollection(this, new ImageFilter(), this.treeLock); } return this.images; } } public HTMLCollection getApplets() { synchronized (this) { if (this.applets == null) { // TODO: Should include OBJECTs that are applets? this.applets = new DescendentHTMLCollection(this, new AppletFilter(), this.treeLock); } return this.applets; } } public HTMLCollection getLinks() { synchronized (this) { if (this.links == null) { this.links = new DescendentHTMLCollection(this, new LinkFilter(), this.treeLock); } return this.links; } } public HTMLCollection getForms() { synchronized (this) { if (this.forms == null) { this.forms = new DescendentHTMLCollection(this, new FormFilter(), this.treeLock); } return this.forms; } } public HTMLCollection getFrames() { synchronized (this) { if (this.frames == null) { this.frames = new DescendentHTMLCollection(this, new FrameFilter(), this.treeLock); } return this.frames; } } public HTMLCollection getAnchors() { synchronized (this) { if (this.anchors == null) { this.anchors = new DescendentHTMLCollection(this, new AnchorFilter(), this.treeLock); } return this.anchors; } } public String getCookie() { // Justification: A caller (e.g. Google Analytics script) // might want to get cookies from the parent document. // If the caller has access to the document, it appears // they should be able to get cookies on that document. // Note that this Document instance cannot be created // with an arbitrary URL. // TODO: Security: Review rationale. return SecurityUtil.doPrivileged(() -> ucontext.getCookie(documentURL)); } public void setCookie(final String cookie) throws DOMException { // Justification: A caller (e.g. Google Analytics script) // might want to set cookies on the parent document. // If the caller has access to the document, it appears // they should be able to set cookies on that document. // Note that this Document instance cannot be created // with an arbitrary URL. SecurityUtil.doPrivileged(() -> { ucontext.setCookie(documentURL, cookie); return null; }); } public void open() { synchronized (this.treeLock) { if (this.reader != null) { if (this.reader instanceof LocalWritableLineReader) { try { this.reader.close(); } catch (final IOException ioe) { // ignore } this.reader = null; } else { // Already open, return. // Do not close http/file documents in progress. return; } } this.removeAllChildrenImpl(); this.reader = new LocalWritableLineReader(new EmptyReader()); } } /** * Loads the document from the reader provided when the current instance of * <code>HTMLDocumentImpl</code> was constructed. It then closes the reader. * * @throws IOException * @throws SAXException * @throws UnsupportedEncodingException */ public void load() throws IOException, SAXException, UnsupportedEncodingException { this.load(true); } public void load(final boolean closeReader) throws IOException, SAXException, UnsupportedEncodingException { WritableLineReader reader; synchronized (this.treeLock) { this.removeAllChildrenImpl(); this.setTitle(null); this.setBaseURI(null); this.setDefaultTarget(null); this.styleSheetManager.invalidateStyles(); reader = this.reader; } if (reader != null) { try { final ErrorHandler errorHandler = new LocalErrorHandler(); final String systemId = this.documentURI; final String publicId = systemId; final HtmlParser parser = new HtmlParser(this.ucontext, this, errorHandler, publicId, systemId, isXML(), true); parser.parse(reader); } finally { if (closeReader) { try { reader.close(); } catch (final Exception err) { logger.log(Level.WARNING, "load(): Unable to close stream", err); } synchronized (this.treeLock) { this.reader = null; } } } } } @HideFromJS public boolean isXML() { return isDocTypeXHTML || "application/xhtml+xml".equals(contentType); } public void close() { synchronized (this.treeLock) { if (this.reader instanceof LocalWritableLineReader) { try { this.reader.close(); } catch (final java.io.IOException ioe) { // ignore } this.reader = null; } else { // do nothing - could be parsing document off the web. } // TODO: cause it to render } } public void write(final String text) { synchronized (this.treeLock) { if (this.reader != null) { try { // This can end up in openBufferChanged this.reader.write(text); } catch (final IOException ioe) { // ignore } } } } public void writeln(final String text) { synchronized (this.treeLock) { if (this.reader != null) { try { // This can end up in openBufferChanged this.reader.write(text + "\r\n"); } catch (final IOException ioe) { // ignore } } } } private void openBufferChanged(final String text) { // Assumed to execute in a lock // Assumed that text is not broken up HTML. final ErrorHandler errorHandler = new LocalErrorHandler(); final String systemId = this.documentURI; final String publicId = systemId; final HtmlParser parser = new HtmlParser(this.ucontext, this, errorHandler, publicId, systemId, false /* TODO */, true); final StringReader strReader = new StringReader(text); try { // This sets up another Javascript scope Window. Does it matter? parser.parse(strReader); } catch (final Exception err) { this.warn("Unable to parse written HTML text. BaseURI=[" + this.getBaseURI() + "].", err); } } /** * Gets the collection of elements whose <code>name</code> attribute is * <code>elementName</code>. */ public NodeList getElementsByName(final String elementName) { return this.getNodeList(new ElementNameFilter(elementName)); } private DocumentType doctype; private boolean isDocTypeXHTML = false; public DocumentType getDoctype() { return this.doctype; } private final static String XHTML_STRICT_PUBLIC_ID = "-//W3C//DTD XHTML 1.0 Strict//EN"; private final static String XHTML_STRICT_SYS_ID = "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"; public void setDoctype(final DocumentType doctype) { this.doctype = doctype; isDocTypeXHTML = (doctype != null) && (doctype.getName().equals("html")) && (doctype.getPublicId().equals(XHTML_STRICT_PUBLIC_ID)) && (doctype.getSystemId().equals(XHTML_STRICT_SYS_ID)); } public Element getDocumentElement() { synchronized (this.treeLock) { final ArrayList<Node> nl = this.nodeList; if (nl != null) { final Iterator<Node> i = nl.iterator(); while (i.hasNext()) { final Object node = i.next(); if (node instanceof Element) { return (Element) node; } } } return null; } } public Element createElement(final String tagName) throws DOMException { return this.factory.createElement(this, tagName); } /* * (non-Javadoc) * * @see org.w3c.dom.Document#createDocumentFragment() */ public DocumentFragment createDocumentFragment() { // TODO: According to documentation, when a document // fragment is added to a node, its children are added, // not itself. final DocumentFragmentImpl node = new DocumentFragmentImpl(); node.setOwnerDocument(this); return node; } public Text createTextNode(final String data) { final TextImpl node = new TextImpl(data); node.setOwnerDocument(this); return node; } public Comment createComment(final String data) { final CommentImpl node = new CommentImpl(data); node.setOwnerDocument(this); return node; } public CDATASection createCDATASection(final String data) throws DOMException { final CDataSectionImpl node = new CDataSectionImpl(data); node.setOwnerDocument(this); return node; } public ProcessingInstruction createProcessingInstruction(final String target, final String data) throws DOMException { final HTMLProcessingInstruction node = new HTMLProcessingInstruction(target, data); node.setOwnerDocument(this); return node; } public Attr createAttribute(final String name) throws DOMException { return new AttrImpl(name); } public EntityReference createEntityReference(final String name) throws DOMException { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "HTML document"); } /** * Gets all elements that match the given tag name. * * @param tagname * The element tag name or an asterisk character (*) to match all * elements. */ public NodeList getElementsByTagName(final String tagname) { if ("*".equals(tagname)) { return this.getNodeList(new ElementFilter()); } else { return this.getNodeList(new TagNameFilter(tagname)); } } public Node importNode(final Node importedNode, final boolean deep) throws DOMException { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "Not implemented"); } public Element createElementNS(final String namespaceURI, final String qualifiedName) throws DOMException { if (namespaceURI == null || (namespaceURI.trim().length() == 0) || "http://www.w3.org/1999/xhtml".equalsIgnoreCase(namespaceURI)) { return createElement(qualifiedName); } else if ("http://www.w3.org/2000/svg".equalsIgnoreCase(namespaceURI)) { // TODO: This is a plug return createElement(qualifiedName); } System.out.println("unhandled request to create element in NS: " + namespaceURI + " with tag: " + qualifiedName); return null; // TODO // throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "Not implemented: createElementNS"); } public Attr createAttributeNS(final String namespaceURI, final String qualifiedName) throws DOMException { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "Not implemented: createAttributeNS"); } public NodeList getElementsByTagNameNS(final String namespaceURI, final String localName) { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "Not implemented: getElementsByTagNameNS"); } public Element getElementById(final String elementId) { if ((elementId != null) && (elementId.length() > 0)) { synchronized (this) { return this.elementsById.get(elementId); } } else { return null; } } private final Map<String, Element> elementsByName = new HashMap<>(0); public Element namedItem(final String name) { Element element; synchronized (this) { element = this.elementsByName.get(name); } return element; } void setNamedItem(final String name, final Element element) { synchronized (this) { this.elementsByName.put(name, element); } } void removeNamedItem(final String name) { synchronized (this) { this.elementsByName.remove(name); } } private String inputEncoding; public String getInputEncoding() { return this.inputEncoding; } private String xmlEncoding; public String getXmlEncoding() { return this.xmlEncoding; } private boolean xmlStandalone; public boolean getXmlStandalone() { return this.xmlStandalone; } public void setXmlStandalone(final boolean xmlStandalone) throws DOMException { this.xmlStandalone = xmlStandalone; } private String xmlVersion = null; public String getXmlVersion() { return this.xmlVersion; } public void setXmlVersion(final String xmlVersion) throws DOMException { this.xmlVersion = xmlVersion; } private boolean strictErrorChecking = true; public boolean getStrictErrorChecking() { return this.strictErrorChecking; } public void setStrictErrorChecking(final boolean strictErrorChecking) { this.strictErrorChecking = strictErrorChecking; } public String getDocumentURI() { return this.documentURI; } public void setDocumentURI(final String documentURI) { // TODO: Security considerations? Chaging documentURL? this.documentURI = documentURI; } public Node adoptNode(final Node source) throws DOMException { if (source instanceof NodeImpl) { final NodeImpl node = (NodeImpl) source; node.setOwnerDocument(this, true); return node; } else { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "Invalid Node implementation"); } } private DOMConfiguration domConfig; public DOMConfiguration getDomConfig() { synchronized (this) { if (this.domConfig == null) { this.domConfig = new DOMConfigurationImpl(); } return this.domConfig; } } public void normalizeDocument() { // TODO: Normalization options from domConfig synchronized (this.treeLock) { this.visitImpl(new NodeVisitor() { public void visit(final Node node) { node.normalize(); } }); } } public Node renameNode(final Node n, final String namespaceURI, final String qualifiedName) throws DOMException { throw new DOMException(DOMException.NOT_SUPPORTED_ERR, "No renaming"); } private DOMImplementation domImplementation; /* * (non-Javadoc) * * @see org.w3c.dom.Document#getImplementation() */ public DOMImplementation getImplementation() { synchronized (this) { if (this.domImplementation == null) { this.domImplementation = new DOMImplementationImpl(this.ucontext); } return this.domImplementation; } } /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#getLocalName() */ @Override public String getLocalName() { // Always null for document return null; } /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#getNodeName() */ @Override public String getNodeName() { return "#document"; } /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#getNodeType() */ @Override public short getNodeType() { return Node.DOCUMENT_NODE; } /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#getNodeValue() */ @Override public String getNodeValue() throws DOMException { // Always null for document return null; } /* * (non-Javadoc) * * @see org.xamjwg.html.domimpl.NodeImpl#setNodeValue(java.lang.String) */ @Override public void setNodeValue(final String nodeValue) throws DOMException { throw new DOMException(DOMException.INVALID_MODIFICATION_ERR, "Cannot set node value of document"); } @Override public final HtmlRendererContext getHtmlRendererContext() { return this.rcontext; } @Override public UserAgentContext getUserAgentContext() { return this.ucontext; } @Override public final @NonNull URL getFullURL(final String uri) throws MalformedURLException { try { final String baseURI = this.getBaseURI(); final URL documentURL = baseURI == null ? null : new URL(baseURI); return Urls.createURL(documentURL, uri); } catch (final MalformedURLException mfu) { return new URL(uri); } } public final Location getLocation() { return this.window.getLocation(); } public void setLocation(final String location) { this.getLocation().setHref(location); } public String getURL() { return this.documentURI; } private HTMLElement body; public void setBody(final HTMLElement body) { synchronized (this) { this.body = body; } } public void allInvalidated(final boolean forgetRenderStates) { if (forgetRenderStates) { synchronized (this.treeLock) { // Need to invalidate all children up to // this point. this.forgetRenderState(); // TODO: this might be ineffcient. final ArrayList<Node> nl = this.nodeList; if (nl != null) { final Iterator<Node> i = nl.iterator(); while (i.hasNext()) { final Object node = i.next(); if (node instanceof HTMLElementImpl) { ((HTMLElementImpl) node).forgetStyle(true); } } } } } this.allInvalidated(); } public StyleSheetList getStyleSheets() { return styleSheetManager.constructStyleSheetList(); } private final ArrayList<DocumentNotificationListener> documentNotificationListeners = new ArrayList<>(1); /** * Adds a document notification listener, which is informed about changes to * the document. * * @param listener * An instance of {@link DocumentNotificationListener}. */ public void addDocumentNotificationListener(final DocumentNotificationListener listener) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; synchronized (listenersList) { listenersList.add(listener); } } public void removeDocumentNotificationListener(final DocumentNotificationListener listener) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; synchronized (listenersList) { listenersList.remove(listener); } } public void sizeInvalidated(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.sizeInvalidated(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } /** * Called if something such as a color or decoration has changed. This would * be something which does not affect the rendered size, and can be * revalidated with a simple repaint. * * @param node */ public void lookInvalidated(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.lookInvalidated(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } /** * Changed if the position of the node in a parent has changed. * * @param node */ public void positionInParentInvalidated(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.positionInvalidated(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } /** * This is called when the node has changed, but it is unclear if it's a size * change or a look change. An attribute change should trigger this. * * @param node */ public void invalidated(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.invalidated(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } /** * This is called when children of the node might have changed. * * @param node */ public void structureInvalidated(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.structureInvalidated(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } public void nodeLoaded(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.nodeLoaded(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } public void externalScriptLoading(final NodeImpl node) { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.externalScriptLoading(node); } catch (final IndexOutOfBoundsException iob) { // ignore } } } /** * Informs listeners that the whole document has been invalidated. */ public void allInvalidated() { final ArrayList<DocumentNotificationListener> listenersList = this.documentNotificationListeners; int size; synchronized (listenersList) { size = listenersList.size(); } // Traverse list outside synchronized block. // (Shouldn't call listener methods in synchronized block. // Deadlock is possible). But assume list could have // been changed. for (int i = 0; i < size; i++) { try { final DocumentNotificationListener dnl = listenersList.get(i); dnl.allInvalidated(); } catch (final IndexOutOfBoundsException iob) { // ignore } } } @Override protected @NonNull RenderState createRenderState(final RenderState prevRenderState) { return new StyleSheetRenderState(this); } private final Map<String, ImageInfo> imageInfos = new HashMap<>(4); private final ImageEvent BLANK_IMAGE_EVENT = new ImageEvent(this, new ImageResponse()); /** * Loads images asynchronously such that they are shared if loaded * simultaneously from the same URI. Informs the listener immediately if an * image is already known. * * @param relativeUri * @param imageListener */ protected void loadImage(final String relativeUri, final ImageListener imageListener) { final HtmlRendererContext rcontext = this.getHtmlRendererContext(); if ((rcontext == null) || !rcontext.isImageLoadingEnabled()) { // Ignore image loading when there's no renderer context. // Consider Cobra users who are only using the parser. imageListener.imageLoaded(BLANK_IMAGE_EVENT); return; } try { final URL url = this.getFullURL(relativeUri); final String urlText = url.toExternalForm(); final Map<String, ImageInfo> map = this.imageInfos; ImageEvent event = null; synchronized (map) { final ImageInfo info = map.get(urlText); if (info != null) { if (info.loaded) { // TODO: This can't really happen because ImageInfo // is removed right after image is loaded. event = info.imageEvent; } else { info.addListener(imageListener); } } else { final UserAgentContext uac = rcontext.getUserAgentContext(); final NetworkRequest httpRequest = uac.createHttpRequest(); final ImageInfo newInfo = new ImageInfo(); map.put(urlText, newInfo); newInfo.addListener(imageListener); httpRequest.addNetworkRequestListener(netEvent -> { if (httpRequest.getReadyState() == NetworkRequest.STATE_COMPLETE) { final ImageResponse imageResponse = httpRequest.getResponseImage(); final ImageEvent newEvent = new ImageEvent(HTMLDocumentImpl.this, imageResponse); ImageListener[] listeners; synchronized (map) { newInfo.imageEvent = newEvent; newInfo.loaded = true; listeners = newInfo.getListeners(); // Must remove from map in the locked block // that got the listeners. Otherwise a new // listener might miss the event?? map.remove(urlText); } if (listeners != null) { final int llength = listeners.length; for (int i = 0; i < llength; i++) { // Call holding no locks listeners[i].imageLoaded(newEvent); } } } else if (httpRequest.getReadyState() == NetworkRequest.STATE_ABORTED) { ImageListener[] listeners; synchronized (map) { newInfo.loaded = true; listeners = newInfo.getListeners(); // Must remove from map in the locked block // that got the listeners. Otherwise a new // listener might miss the event?? map.remove(urlText); } if (listeners != null) { final int llength = listeners.length; for (int i = 0; i < llength; i++) { // Call holding no locks listeners[i].imageAborted(); } } } }); SecurityUtil.doPrivileged(() -> { try { httpRequest.open("GET", url); httpRequest.send(null, new Request(url, RequestKind.Image)); } catch (final java.io.IOException thrown) { logger.log(Level.WARNING, "loadImage()", thrown); } return null; }); } } if (event != null) { // Call holding no locks. imageListener.imageLoaded(event); } } catch (final MalformedURLException mfe) { imageListener.imageLoaded(BLANK_IMAGE_EVENT); return; } } private Function onloadHandler; private final List<Function> onloadHandlers = new ArrayList<>(); public Function getOnloadHandler() { return onloadHandler; } public void setOnloadHandler(final Function onloadHandler) { this.onloadHandler = onloadHandler; } @Override public Object setUserData(final String key, final Object data, final UserDataHandler handler) { // if (org.lobobrowser.html.parser.HtmlParser.MODIFYING_KEY.equals(key) && data == Boolean.FALSE) { // dispatchLoadEvent(); // } return super.setUserData(key, data, handler); } private void dispatchLoadEvent() { final Function onloadHandler = this.onloadHandler; if (onloadHandler != null) { // TODO: onload event object? throw new UnsupportedOperationException(); // TODO: Use the event dispatcher // Executor.executeFunction(this, onloadHandler, null); } // final Event loadEvent = new Event("load", getBody()); // TODO: What should be the target for this event? // dispatchEventToHandlers(loadEvent, onloadHandlers); final Event domContentLoadedEvent = new Event("DOMContentLoaded", getBody()); // TODO: What should be the target for this event? dispatchEvent(domContentLoadedEvent); window.domContentLoaded(domContentLoadedEvent); } protected EventTargetManager getEventTargetManager() { return window.getEventTargetManager(); } @Override protected Node createSimilarNode() { return new HTMLDocumentImpl(this.ucontext, this.rcontext, this.reader, this.documentURI, this.contentType); } private static class ImageInfo { // Access to this class is synchronized on imageInfos. public ImageEvent imageEvent; public boolean loaded; private final ArrayList<ImageListener> listeners = new ArrayList<>(1); void addListener(final ImageListener listener) { this.listeners.add(listener); } ImageListener[] getListeners() { return this.listeners.toArray(ImageListener.EMPTY_ARRAY); } } /** * Tag class that also notifies document when text is written to an open * buffer. * * @author J. H. S. */ private class LocalWritableLineReader extends WritableLineReader { /** * @param reader */ public LocalWritableLineReader(final LineNumberReader reader) { super(reader); } /** * @param reader */ public LocalWritableLineReader(final Reader reader) { super(reader); } @Override public void write(final String text) throws IOException { super.write(text); if ("".equals(text)) { openBufferChanged(text); } } } @HideFromJS public void addLoadHandler(final Function handler) { onloadHandlers.add(handler); } @HideFromJS public void removeLoadHandler(final Function handler) { onloadHandlers.remove(handler); } private List<Runnable> jobs = new LinkedList<>(); private final AtomicInteger registeredJobs = new AtomicInteger(0); private final AtomicInteger layoutBlockingJobs = new AtomicInteger(0); private final Semaphore doneAllJobs = new Semaphore(0); private final AtomicBoolean stopRequested = new AtomicBoolean(false); private int oldPendingTaskId = -1; @HideFromJS public void stopEverything() { if (stopRequested.get()) { throw new IllegalStateException("Stop requested twice!"); } stopRequested.set(true); if (modificationsStarted.get()) { boolean done = false; while (!done) { try { doneAllJobs.acquire(); done = true; } catch (final InterruptedException e) { e.printStackTrace(); } } } } @HideFromJS public void addJob(final Runnable job, final boolean layoutBlocker) { addJob(job, layoutBlocker, 1); } @HideFromJS public void addJob(final Runnable job, final boolean layoutBlocker, final int incr) { synchronized (jobs) { registeredJobs.addAndGet(incr); if (layoutBlocker) { layoutBlockingJobs.addAndGet(incr); } jobs.add(job); // Added into synch block because of the JS Uniq task change. (old Id should be protected from parallel mod) if (modificationsOver.get()) { // TODO: temp hack. Not sure if spawning an entirely new thread is right. But it helps with a deadlock in // test_script_iframe_load (test number 3) // new Thread() { // public void run() { // runAllPending(); // }; // }.start(); // TODO: temp hack 2. This seems more legitimate than hack #1. /* window.addJSTask(new JSRunnableTask(0, "todo: quick check to run all pending jobs", () -> { runAllPending(); })); */ // TODO: temp hack 3. This seems more legitimate than hack #1 and optimisation over #2. oldPendingTaskId = window.addJSUniqueTask(oldPendingTaskId, new JSRunnableTask(0, "todo: quick check to run all pending jobs", () -> { runAllPending(); })); // runAllPending(); } } } private void runAllPending() { boolean done = false; while (!done && !stopRequested.get()) { List<Runnable> jobsCopy; synchronized (jobs) { jobsCopy = jobs; jobs = new LinkedList<>(); } jobsCopy.forEach(j -> j.run()); synchronized (jobs) { done = jobs.size() == 0; } } doneAllJobs.release(); } private Holder classifiedRules = null; private static final StyleSheet recommendedStyle = parseStyle(CSSNorm.stdStyleSheet(), StyleSheet.Origin.AGENT, false); private static final StyleSheet userAgentStyle = parseStyle(CSSNorm.userStyleSheet(), StyleSheet.Origin.AGENT, false); private static final StyleSheet recommendedStyleXML = parseStyle(CSSNorm.stdStyleSheet(), StyleSheet.Origin.AGENT, true); private static final StyleSheet userAgentStyleXML = parseStyle(CSSNorm.userStyleSheet(), StyleSheet.Origin.AGENT, true); private void updateStyleRules() { synchronized (treeLock) { if (classifiedRules == null) { final List<StyleSheet> jSheets = new ArrayList<>(); jSheets.add(isXML() ? recommendedStyleXML : recommendedStyle); jSheets.add(isXML() ? userAgentStyleXML : userAgentStyle); jSheets.addAll(styleSheetManager.getEnabledJStyleSheets()); classifiedRules = AnalyzerUtil.getClassifiedRules(jSheets, new MediaSpec("screen")); } } } ElementMatcher getMatcher() { return isXML() ? xhtmlMatcher : stdMatcher; } /** * Visits all elements and computes their styles. This is faster than * computing them separately when needed. Note: If styles were to be stored as * soft / weak references, this method will lose its value. */ @HideFromJS public void primeNodeData() { visit((node) -> { if (node instanceof HTMLElementImpl) { HTMLElementImpl he = (HTMLElementImpl) node; he.getCurrentStyle(); } }); } Holder getClassifiedRules() { synchronized (treeLock) { if (classifiedRules == null) { updateStyleRules(); } return classifiedRules; } } final static ElementMatcher xhtmlMatcher = new ElementMatcherSafeCS(); final static ElementMatcher stdMatcher = new ElementMatcherSafeStd(); private static StyleSheet parseStyle(final String cssdata, final StyleSheet.Origin origin, final boolean isXML) { try { final StyleSheet newsheet = CSSParserFactory.getInstance().parse(cssdata, null, null, SourceType.EMBEDDED, null); newsheet.setOrigin(origin); return newsheet; } catch (IOException | CSSException e) { throw new RuntimeException(e); } } // TODO: Synchronize? @HideFromJS public void markJobsFinished(final int numJobs, final boolean layoutBlocker) { final int curr = registeredJobs.addAndGet(-numJobs); final int layoutBlockers = layoutBlocker ? layoutBlockingJobs.addAndGet(-numJobs) : layoutBlockingJobs.get(); if (layoutBlocked.get()) { if (layoutBlockers == 0) { layoutBlocked.set(false); allInvalidated(); } } if (curr < 0) { throw new IllegalStateException("More jobs over than registered!"); } else if (curr == 0) { if (!stopRequested.get() && !loadOver.get()) { loadOver.set(true); dispatchLoadEvent(); // System.out.println("In " + baseURI); // System.out.println(" calling window.jobsFinished()"); rcontext.jobsFinished(); window.jobsFinished(); } } } private final AtomicBoolean modificationsStarted = new AtomicBoolean(false); private final AtomicBoolean modificationsOver = new AtomicBoolean(false); private final AtomicBoolean loadOver = new AtomicBoolean(false); public final AtomicBoolean layoutBlocked = new AtomicBoolean(true); @HideFromJS public void finishModifications() { StyleElements.normalizeHTMLTree(this); // TODO: Not sure if this should be run in new thread. But this blocks the UI sometimes when it is in the same thread, and a network request hangs. // There is a race condition here, when iframes are involved. // The thread creation can probably be removed as part of GH #140 new Thread(() -> { modificationsStarted.set(true); runAllPending(); modificationsOver.set(true); }).start(); // This is to trigger a check in the no external resource case. // On second thoughts, this may not be required. The window load event need only be fired if there is a script // On third thoughs, this also affects frame that embed iframes markJobsFinished(0, false); /* Nodes.forEachNode(document, node -> { if (node instanceof NodeImpl) { final NodeImpl element = (NodeImpl) node; Object oldData = element.getUserData(org.lobobrowser.html.parser.HtmlParser.MODIFYING_KEY); if (oldData == null || !oldData.equals(Boolean.FALSE)) { element.setUserData(org.lobobrowser.html.parser.HtmlParser.MODIFYING_KEY, Boolean.FALSE, null); } } });*/ } final class StyleSheetManager { private volatile List<JStyleSheetWrapper> styleSheets = null; final StyleSheetBridge bridge = new StyleSheetBridge() { public void notifyStyleSheetChanged(final CSSStyleSheet styleSheet) { final Node ownerNode = styleSheet.getOwnerNode(); if (ownerNode != null) { final boolean disabled = styleSheet.getDisabled(); if (ownerNode instanceof HTMLStyleElementImpl) { final HTMLStyleElementImpl htmlStyleElement = (HTMLStyleElementImpl) ownerNode; if (htmlStyleElement.getDisabled() != disabled) { htmlStyleElement.setDisabledImpl(disabled); } } else if (ownerNode instanceof HTMLLinkElementImpl) { final HTMLLinkElementImpl htmlLinkElement = (HTMLLinkElementImpl) ownerNode; if (htmlLinkElement.getDisabled() != disabled) { htmlLinkElement.setDisabledImpl(disabled); } } } invalidateStyles(); allInvalidated(); } public List<JStyleSheetWrapper> getDocStyleSheets() { return getDocStyleSheetList(); } }; private List<JStyleSheetWrapper> getDocStyleSheetList() { synchronized (this) { if (styleSheets == null) { styleSheets = new ArrayList<>(); final List<JStyleSheetWrapper> docStyles = new ArrayList<>(); synchronized (treeLock) { scanElementStyleSheets(docStyles, HTMLDocumentImpl.this); } styleSheets.addAll(docStyles); // System.out.println("Found stylesheets: " + this.styleSheets.size()); } return this.styleSheets; } } private void scanElementStyleSheets(final List<JStyleSheetWrapper> styles, final Node node) { if (node instanceof LinkStyle) { final LinkStyle linkStyle = (LinkStyle) node; final JStyleSheetWrapper sheet = (JStyleSheetWrapper) linkStyle.getSheet(); if (sheet != null) { styles.add(sheet); } } if (node.hasChildNodes()) { final NodeList nodeList = node.getChildNodes(); for (int i = 0; i < nodeList.getLength(); i++) { scanElementStyleSheets(styles, nodeList.item(i)); } } } private volatile List<cz.vutbr.web.css.StyleSheet> enabledJStyleSheets = null; // TODO enabled style sheets can be cached List<cz.vutbr.web.css.StyleSheet> getEnabledJStyleSheets() { synchronized (this) { if (enabledJStyleSheets != null) { return enabledJStyleSheets; } final List<JStyleSheetWrapper> documentStyles = this.getDocStyleSheetList(); final List<cz.vutbr.web.css.StyleSheet> jStyleSheets = new ArrayList<>(); for (final JStyleSheetWrapper style : documentStyles) { if ((!style.getDisabled()) && (style.getJStyleSheet() != null)) { jStyleSheets.add(style.getJStyleSheet()); } } enabledJStyleSheets = jStyleSheets; return jStyleSheets; } } void invalidateStyles() { synchronized (treeLock) { this.styleSheets = null; getDocStyleSheetList(); } synchronized (this) { this.enabledJStyleSheets = null; } synchronized (treeLock) { HTMLDocumentImpl.this.classifiedRules = null; } // System.out.println("Stylesheets set to null"); allInvalidated(true); } StyleSheetList constructStyleSheetList() { return JStyleSheetWrapper.getStyleSheets(bridge); } } @Override public void addEventListener(final String type, final EventListener listener, final boolean useCapture) { // TODO Auto-generated method stub throw new UnsupportedOperationException(); } @Override public void removeEventListener(final String type, final EventListener listener, final boolean useCapture) { // TODO Auto-generated method stub throw new UnsupportedOperationException(); } @Override public boolean dispatchEvent(final org.w3c.dom.events.Event evt) throws EventException { // TODO Auto-generated method stub return false; } public Event createEvent(final String type ) { return new Event(type, this); } public Range createRange() { return new RangeImpl(this); } public boolean hasFocus() { // TODO: Plug return true; } }