1   package org.daisy.pipeline.html.impl;
2   
3   import java.io.InputStream;
4   import java.io.InputStreamReader;
5   import java.io.IOException;
6   import java.io.Reader;
7   import java.io.StringReader;
8   import java.net.MalformedURLException;
9   import java.net.URI;
10  import java.net.URL;
11  import java.nio.charset.Charset;
12  import java.nio.charset.IllegalCharsetNameException;
13  import java.nio.charset.StandardCharsets;
14  import java.nio.charset.UnsupportedCharsetException;
15  import java.util.Iterator;
16  import java.util.NoSuchElementException;
17  import java.util.regex.Pattern;
18  
19  import javax.xml.transform.dom.DOMSource;
20  import javax.xml.transform.sax.SAXSource;
21  import javax.xml.transform.Source;
22  import javax.xml.transform.TransformerException;
23  import javax.xml.transform.URIResolver;
24  
25  import com.google.common.io.ByteStreams;
26  import com.google.common.io.CharStreams;
27  
28  import com.xmlcalabash.util.TreeWriter;
29  
30  import net.sf.saxon.dom.DocumentOverNodeInfo;
31  import net.sf.saxon.s9api.Processor;
32  import net.sf.saxon.s9api.SaxonApiException;
33  import net.sf.saxon.s9api.XdmNode;
34  
35  import nu.validator.htmlparser.common.XmlViolationPolicy;
36  
37  import org.daisy.common.file.URLs;
38  
39  import org.daisy.common.saxon.xslt.XslTransformCompiler;
40  import org.daisy.common.xml.DocumentBuilder;
41  
42  import org.osgi.service.component.annotations.Component;
43  import org.osgi.service.component.annotations.Reference;
44  import org.osgi.service.component.annotations.ReferenceCardinality;
45  import org.osgi.service.component.annotations.ReferencePolicy;
46  
47  import org.w3c.dom.Attr;
48  import org.w3c.dom.Document;
49  import org.w3c.dom.Element;
50  import org.w3c.dom.Node;
51  import org.w3c.dom.NodeList;
52  
53  import org.xml.sax.EntityResolver;
54  import org.xml.sax.InputSource;
55  import org.xml.sax.SAXException;
56  
57  @Component(
58  	name = "html-document-builder",
59  	service = { DocumentBuilder.class }
60  )
61  public class HtmlDocumentBuilder implements DocumentBuilder {
62  
63  	private final static String MIME_HTML = "text/html";
64  	private final static String MIME_XHTML = "application/xhtml+xml";
65  
66  	/**
67  	 * Test whether the given content media type is "text/html" or "application/xhtml+xml".
68  	 */
69  	@Override
70  	public boolean supportsContentType(String type) {
71  		if (MIME_HTML.equals(type))
72  			return true;
73  		else if (xmlParser != null && MIME_XHTML.equals(type))
74  			return true;
75  		else
76  			return false;
77  	}
78  
79  	/**
80  	 * @param input an HTML file
81  	 */
82  	@Override
83  	public Document parse(InputSource input) throws IOException, SAXException {
84  		if (xmlParser != null)
85  			try {
86  				return xmlParser.parse(input);
87  			} catch (SAXException|IllegalArgumentException e) {
88  				// ignore
89  			}
90  		URI baseURI; {
91  			try {
92  				baseURI = getBaseURI(input);
93  			} catch (IllegalArgumentException e) {
94  				if (input.getCharacterStream() == null && input.getByteStream() == null)
95  					throw new IOException("Error reading input: invalid URI", e);
96  				else
97  					throw e;
98  			}
99  		}
100 		if (input.getCharacterStream() == null && input.getByteStream() == null) {
101 			// resolve URI
102 			if (baseURI == null)
103 				throw new IOException(
104 					"Error reading input: no character or byte stream available and no base URI");
105 			Source s; {
106 				try {
107 					s = uriResolver.resolve(baseURI.toASCIIString(), null);
108 				} catch (TransformerException e) {
109 					throw new IOException("Error reading input: " + baseURI, e);
110 				}
111 			}
112 			if (s != null) {
113 				input = SAXSource.sourceToInputSource(s);
114 				if (input == null)
115 					throw new IOException("Error reading input: " + baseURI);
116 			}
117 		}
118 		Reader reader = input.getCharacterStream();
119 		String text;
120 		byte[] bytes = null;
121 		boolean checkEncoding = false;;
122 		if (reader != null)
123 			text = CharStreams.toString(reader);
124 		else {
125 			checkEncoding = true;
126 			bytes = readBytes(input);
127 			text = new String(bytes, StandardCharsets.UTF_8);
128 		}
129 		text = removeDoctype(text);
130 		Document doc = readHtml(text);
131 		if (checkEncoding) {
132 			for (Node n : iterateNodeList(doc.getDocumentElement().getElementsByTagName("meta"))) {
133 				Element elem = (Element)n;
134 				Attr httpEquiv = elem.getAttributeNode("http-equiv");
135 				if (httpEquiv != null && "Content-Type".equals(httpEquiv.getValue())) {
136 					Attr contentType = elem.getAttributeNode("content");
137 					if (contentType != null) {
138 						String charset = contentType.getValue()
139 						                            .replaceAll(".*?charset\\s*=\\s*('(.*?)'|\"(.*?)\"|([^'\"\\s][^\\s;]*)|.).*",
140 						                                        "$2$3$4");
141 						if (!charset.equals(contentType.getValue())) {
142 							Charset encoding; {
143 								try {
144 									encoding = Charset.forName(charset.toLowerCase());
145 								} catch (IllegalCharsetNameException e) {
146 									throw new IOException("Invalid charset: " + charset, e);
147 								} catch (UnsupportedCharsetException e) {
148 									throw new IOException("Unsupported charset: " + charset, e);
149 								}
150 							}
151 							if (!StandardCharsets.UTF_8.equals(encoding)) {
152 								text = new String(bytes, encoding);
153 								text = removeDoctype(text);
154 								doc = readHtml(text);
155 							}
156 						}
157 						break;
158 					}
159 				}
160 			}
161 		}
162 		// use Saxon to apply namespace-fixup.xsl and set base URI
163 		try {
164 			XdmNode saxonDoc = saxonProcessor.newDocumentBuilder().build(new DOMSource(doc));
165 			saxonDoc = new XslTransformCompiler(saxonProcessor.getUnderlyingConfiguration())
166 			    .compileStylesheet(URLs.getResourceFromJAR("/xml/xslt/namespace-fixup.xsl", HtmlDocumentBuilder.class)
167 			                           .openStream())
168 			    .newTransformer()
169 			    .transform(saxonDoc);
170 			if (baseURI != null) {
171 				// for some reason setting the output base URI on the XSLT transformation doesn't work properly
172 				// as a workaround use XMLCalabash's TreeWriter to set the base URI
173 				TreeWriter fixbase = new TreeWriter(saxonProcessor);
174 				fixbase.startDocument(baseURI);
175 				fixbase.addSubtree(saxonDoc);
176 				fixbase.endDocument();
177 				saxonDoc = fixbase.getResult();
178 			}
179 			doc = (Document)DocumentOverNodeInfo.wrap(saxonDoc.getUnderlyingNode());
180 		} catch (IOException|SaxonApiException e) {
181 			throw new SAXException("Error parsing input", e);
182 		}
183 		return doc;
184 	}
185 
186 	/**
187 	 * @return {@code null} if the input has no base URI
188 	 * @throws IllegalArgumentException if the input has an invalid base URI
189 	 */
190 	private static URI getBaseURI(InputSource input) throws IllegalArgumentException {
191 		String sysId = input.getSystemId();
192 		if (sysId == null || "".equals(sysId))
193 			return null;
194 		try {
195 			return URI.create(sysId);
196 		} catch (IllegalArgumentException e) {
197 			throw new IllegalArgumentException("Invalid base URI: " + sysId, e);
198 		}
199 	}
200 
201 	private static byte[] readBytes(InputSource input) throws IOException {
202 		if (input.getCharacterStream() != null)
203 			throw new IllegalArgumentException();
204 		InputStream s = input.getByteStream();
205 		if (s == null) {
206 			URI uri; {
207 				try {
208 					uri = getBaseURI(input);
209 					if (uri == null)
210 						throw new IOException(
211 							"Error reading input: no character or byte stream available and no base URI");
212 				} catch (IllegalArgumentException e) {
213 					throw new IOException("Error reading input: invalid URI", e);
214 				}
215 			}
216 			URL url; {
217 				try {
218 					url = uri.toURL();
219 				} catch (IllegalArgumentException e) {
220 					// not absolute
221 					throw new IOException( "Error reading input: not an absolute URI: " + uri, e);
222 				} catch (MalformedURLException e) {
223 					throw new IOException( "Error reading input: malformed URL: " + uri, e);
224 				}
225 			}
226 			s = url.openStream();
227 		}
228 		return ByteStreams.toByteArray(s);
229 	}
230 
231 	private static String removeDoctype(String input) {
232 		return Pattern.compile("^<[!\\?].*?(<[^!\\?])", Pattern.DOTALL).matcher(input).replaceFirst("$1");
233 	}
234 
235 	private Document readHtml(String input) throws SAXException, IOException {
236 		nu.validator.htmlparser.dom.HtmlDocumentBuilder builder
237 			= new nu.validator.htmlparser.dom.HtmlDocumentBuilder(XmlViolationPolicy.ALTER_INFOSET);
238 		builder.setEntityResolver(entityResolver);
239 		try {
240 			return builder.parse(new InputSource(new StringReader(input)));
241 		} catch (IOException e) {
242 			throw e;
243 		} catch (SAXException e) {
244 			throw e;
245 		}
246 	}
247 
248 	private static Iterable<Node> iterateNodeList(NodeList nodeList) {
249 		int len = nodeList.getLength();
250 		return new Iterable<Node>() {
251 			@Override
252 			public Iterator<Node> iterator() {
253 				return new Iterator() {
254 					int i = 0;
255 					@Override
256 					public boolean hasNext() {
257 						return i < len;
258 					}
259 					@Override
260 					public Node next() throws NoSuchElementException {
261 						if (!hasNext())
262 							throw new NoSuchElementException();
263 						return nodeList.item(i++);
264 					}
265 				};
266 			}
267 		};
268 	}
269 
270 	private DocumentBuilder xmlParser = null;
271 	private URIResolver uriResolver = null;
272 	private EntityResolver entityResolver = null;
273 	private Processor saxonProcessor = null;
274 
275 	// FIXME: this is brittle: it will be a cyclic dependency if at least one other DocumentBuilder
276 	// implementation would bind DocumentBuilder
277 	@Reference(
278 		name = "xml-parser",
279 		unbind = "-",
280 		service = DocumentBuilder.class,
281 		cardinality = ReferenceCardinality.MULTIPLE,
282 		policy = ReferencePolicy.STATIC
283 	)
284 	public void setXmlParser(DocumentBuilder parser) {
285 		if (xmlParser == null && parser.supportsContentType(MIME_XHTML))
286 			xmlParser = parser;
287 	}
288 
289 	@Reference(
290 		name = "uri-resolver",
291 		unbind = "-",
292 		service = URIResolver.class,
293 		cardinality = ReferenceCardinality.MANDATORY,
294 		policy = ReferencePolicy.STATIC
295 	)
296 	public void setURIResolver(URIResolver resolver) {
297 		uriResolver = resolver;
298 	}
299 
300 	@Reference(
301 		name = "entity-resolver",
302 		unbind = "-",
303 		service = EntityResolver.class,
304 		cardinality = ReferenceCardinality.MANDATORY,
305 		policy = ReferencePolicy.STATIC
306 	)
307 	public void setEntityResolver(EntityResolver resolver) {
308 		entityResolver = resolver;
309 	}
310 
311 	@Reference(
312 		name = "saxon-processor",
313 		unbind = "-",
314 		service = Processor.class,
315 		cardinality = ReferenceCardinality.MANDATORY,
316 		policy = ReferencePolicy.STATIC
317 	)
318 	public void setSaxonProcessor(Processor processor) {
319 	    saxonProcessor = processor;
320 	}
321 }