001// Copyright 2009-2013 The Apache Software Foundation 002// 003// Licensed under the Apache License, Version 2.0 (the "License"); 004// you may not use this file except in compliance with the License. 005// You may obtain a copy of the License at 006// 007// http://www.apache.org/licenses/LICENSE-2.0 008// 009// Unless required by applicable law or agreed to in writing, software 010// distributed under the License is distributed on an "AS IS" BASIS, 011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 012// See the License for the specific language governing permissions and 013// limitations under the License. 014 015package org.apache.tapestry5.internal.services; 016 017import org.apache.tapestry5.commons.Location; 018import org.apache.tapestry5.commons.Resource; 019import org.apache.tapestry5.commons.util.CollectionFactory; 020import org.apache.tapestry5.commons.util.ExceptionUtils; 021import org.apache.tapestry5.ioc.internal.util.InternalUtils; 022import org.apache.tapestry5.ioc.internal.util.LocationImpl; 023import org.xml.sax.*; 024import org.xml.sax.ext.Attributes2; 025import org.xml.sax.ext.LexicalHandler; 026import org.xml.sax.helpers.XMLReaderFactory; 027 028import javax.xml.namespace.QName; 029import java.io.*; 030import java.net.URL; 031import java.util.Collections; 032import java.util.List; 033import java.util.Map; 034 035/** 036 * Parses a document as a stream of XML tokens. It includes a special hack (as of Tapestry 5.3) to support the HTML5 doctype ({@code <!DOCTYPE html>}) 037 * as if it were the XHTML transitional doctype 038 * ({@code <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">}). 039 */ 040public class XMLTokenStream 041{ 042 043 public static final String TRANSITIONAL_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">"; 044 045 private static final DTDData HTML5_DTD_DATA = new DTDData("html", null, null); 046 047 private final class SaxHandler implements LexicalHandler, EntityResolver, ContentHandler 048 { 049 private Locator locator; 050 051 private int currentLine = -1; 052 053 private Location cachedLocation; 054 055 private Location textLocation; 056 057 private final StringBuilder builder = new StringBuilder(); 058 059 private boolean inCDATA, insideDTD; 060 061 private List<NamespaceMapping> namespaceMappings = CollectionFactory.newList(); 062 063 private Location getLocation() 064 { 065 if (locator == null) 066 { 067 if (cachedLocation == null) 068 { 069 cachedLocation = new LocationImpl(resource); 070 } 071 } else { 072 int line = locator.getLineNumber(); 073 074 if (currentLine != line) 075 cachedLocation = null; 076 077 if (cachedLocation == null) 078 { 079 // lineOffset accounts for the extra line when a doctype is injected. The line number reported 080 // from the XML parser inlcudes the phantom doctype line, the lineOffset is used to subtract one 081 // to get the real line number. 082 cachedLocation = new LocationImpl(resource, line + lineOffset); 083 } 084 } 085 086 return cachedLocation; 087 } 088 089 private XMLToken add(XMLTokenType type) 090 { 091 XMLToken token = new XMLToken(type, getLocation()); 092 093 tokens.add(token); 094 095 return token; 096 } 097 098 public InputSource resolveEntity(String publicId, String systemId) throws SAXException, 099 IOException 100 { 101 URL url = publicIdToURL.get(publicId); 102 103 try 104 { 105 if (url != null) 106 return new InputSource(url.openStream()); 107 } catch (IOException ex) 108 { 109 throw new SAXException(String.format("Unable to open stream for resource %s: %s", 110 url, ExceptionUtils.toMessage(ex)), ex); 111 } 112 113 return null; 114 } 115 116 public void comment(char[] ch, int start, int length) throws SAXException 117 { 118 if (insideDTD) 119 return; 120 121 // TODO: Coalesce? 122 add(XMLTokenType.COMMENT).text = new String(ch, start, length); 123 } 124 125 public void startCDATA() throws SAXException 126 { 127 // TODO: Flush characters? 128 129 inCDATA = true; 130 } 131 132 public void endCDATA() throws SAXException 133 { 134 if (builder.length() != 0) 135 { 136 add(XMLTokenType.CDATA).text = builder.toString(); 137 } 138 139 builder.setLength(0); 140 inCDATA = false; 141 } 142 143 public void characters(char[] ch, int start, int length) throws SAXException 144 { 145 if (inCDATA) 146 { 147 builder.append(ch, start, length); 148 return; 149 } 150 151 XMLToken token = new XMLToken(XMLTokenType.CHARACTERS, textLocation); 152 token.text = new String(ch, start, length); 153 154 tokens.add(token); 155 } 156 157 public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException 158 { 159 characters(ch, start, length); 160 } 161 162 public void startDTD(final String name, final String publicId, final String systemId) 163 throws SAXException 164 { 165 insideDTD = true; 166 167 if (!ignoreDTD) 168 { 169 DTDData data = html5DTD ? HTML5_DTD_DATA : new DTDData(name, publicId, systemId); 170 171 add(XMLTokenType.DTD).dtdData = data; 172 } 173 } 174 175 public void endDocument() throws SAXException 176 { 177 add(XMLTokenType.END_DOCUMENT); 178 } 179 180 public void endElement(String uri, String localName, String qName) throws SAXException 181 { 182 add(XMLTokenType.END_ELEMENT); 183 } 184 185 public void setDocumentLocator(Locator locator) 186 { 187 this.locator = locator; 188 } 189 190 /** 191 * Checks for the extra namespace injected when the transitional doctype is injected (which 192 * occurs when the template contains no doctype). 193 */ 194 private boolean ignoreURI(String uri) 195 { 196 return ignoreDTD && uri.equals("http://www.w3.org/1999/xhtml"); 197 } 198 199 public void startElement(String uri, String localName, String qName, Attributes attributes) 200 throws SAXException 201 { 202 XMLToken token = add(XMLTokenType.START_ELEMENT); 203 204 token.uri = ignoreURI(uri) ? "" : uri; 205 token.localName = localName; 206 token.qName = qName; 207 208 // The XML parser tends to reuse the same Attributes object, so 209 // capture the data out of it. 210 211 Attributes2 a2 = (attributes instanceof Attributes2) ? (Attributes2) attributes : null; 212 213 if (attributes.getLength() == 0) 214 { 215 token.attributes = Collections.emptyList(); 216 } else 217 { 218 token.attributes = CollectionFactory.newList(); 219 220 for (int i = 0; i < attributes.getLength(); i++) 221 { 222 // Filter out attributes that are not present in the XML input stream, but were 223 // instead provided by DTD defaulting. 224 225 if (a2 != null && !a2.isSpecified(i)) 226 { 227 continue; 228 } 229 230 String prefixedName = attributes.getQName(i); 231 232 int lastColon = prefixedName.lastIndexOf(':'); 233 234 String prefix = lastColon > 0 ? prefixedName.substring(0, lastColon) : ""; 235 236 QName qname = new QName(attributes.getURI(i), attributes.getLocalName(i), 237 prefix); 238 239 token.attributes.add(new AttributeInfo(qname, attributes.getValue(i))); 240 } 241 } 242 243 token.namespaceMappings = CollectionFactory.newList(namespaceMappings); 244 245 namespaceMappings.clear(); 246 247 // Any text collected starts here as well: 248 249 textLocation = getLocation(); 250 } 251 252 public void startPrefixMapping(String prefix, String uri) throws SAXException 253 { 254 if (ignoreDTD && prefix.equals("") && uri.equals("http://www.w3.org/1999/xhtml")) 255 { 256 return; 257 } 258 259 namespaceMappings.add(new NamespaceMapping(prefix, uri)); 260 } 261 262 public void endDTD() throws SAXException 263 { 264 insideDTD = false; 265 } 266 267 public void endEntity(String name) throws SAXException 268 { 269 } 270 271 public void startEntity(String name) throws SAXException 272 { 273 } 274 275 public void endPrefixMapping(String prefix) throws SAXException 276 { 277 } 278 279 public void processingInstruction(String target, String data) throws SAXException 280 { 281 } 282 283 public void skippedEntity(String name) throws SAXException 284 { 285 } 286 287 public void startDocument() throws SAXException 288 { 289 } 290 } 291 292 private int cursor = -1; 293 294 private final List<XMLToken> tokens = CollectionFactory.newList(); 295 296 private final Resource resource; 297 298 private final Map<String, URL> publicIdToURL; 299 300 private Location exceptionLocation; 301 302 private boolean html5DTD, ignoreDTD; 303 304 private int lineOffset; 305 306 public XMLTokenStream(Resource resource, Map<String, URL> publicIdToURL) 307 { 308 this.resource = resource; 309 this.publicIdToURL = publicIdToURL; 310 } 311 312 public void parse() throws SAXException, IOException 313 { 314 SaxHandler handler = new SaxHandler(); 315 316 XMLReader reader = XMLReaderFactory.createXMLReader(); 317 318 reader.setContentHandler(handler); 319 reader.setEntityResolver(handler); 320 reader.setProperty("http://xml.org/sax/properties/lexical-handler", handler); 321 322 InputStream stream = null; 323 324 try 325 { 326 stream = openStream(); 327 reader.parse(new InputSource(stream)); 328 } catch (IOException ex) 329 { 330 this.exceptionLocation = handler.getLocation(); 331 332 throw ex; 333 } catch (SAXException ex) 334 { 335 this.exceptionLocation = handler.getLocation(); 336 337 throw ex; 338 } catch (RuntimeException ex) 339 { 340 this.exceptionLocation = handler.getLocation(); 341 342 throw ex; 343 } finally 344 { 345 InternalUtils.close(stream); 346 } 347 } 348 349 enum State 350 { 351 MAYBE_XML, MAYBE_DOCTYPE, JUST_COPY 352 } 353 354 private InputStream openStream() throws IOException 355 { 356 InputStream rawStream = resource.openStream(); 357 358 String transformationEncoding = "UTF8"; 359 360 InputStreamReader rawReader = new InputStreamReader(rawStream, transformationEncoding); 361 LineNumberReader reader = new LineNumberReader(rawReader); 362 363 ByteArrayOutputStream bos = new ByteArrayOutputStream(5000); 364 PrintWriter writer = new PrintWriter(new OutputStreamWriter(bos, transformationEncoding)); 365 366 State state = State.MAYBE_XML; 367 368 try 369 { 370 while (true) 371 { 372 String line = reader.readLine(); 373 374 if (line == null) 375 { 376 break; 377 } 378 379 switch (state) 380 { 381 382 case MAYBE_XML: 383 384 if (line.toLowerCase().startsWith("<?xml")) 385 { 386 writer.println(line); 387 state = State.MAYBE_DOCTYPE; 388 continue; 389 } 390 391 case MAYBE_DOCTYPE: 392 393 if (line.trim().length() == 0) 394 { 395 writer.println(line); 396 continue; 397 } 398 399 String lineLower = line.toLowerCase(); 400 401 if (lineLower.equals("<!doctype html>")) 402 { 403 html5DTD = true; 404 writer.println(TRANSITIONAL_DOCTYPE); 405 state = State.JUST_COPY; 406 continue; 407 } 408 409 410 if (lineLower.startsWith("<!doctype")) 411 { 412 writer.println(line); 413 state = State.JUST_COPY; 414 continue; 415 } 416 417 // No doctype, let's provide one. 418 419 ignoreDTD = true; 420 lineOffset = -1; 421 writer.println(TRANSITIONAL_DOCTYPE); 422 423 state = State.JUST_COPY; 424 425 // And drop down to writing out the actual line, and all following lines. 426 427 case JUST_COPY: 428 writer.println(line); 429 } 430 } 431 } finally 432 { 433 writer.close(); 434 reader.close(); 435 } 436 437 return new ByteArrayInputStream(bos.toByteArray()); 438 } 439 440 private XMLToken token() 441 { 442 return cursor == -1 ? null : tokens.get(cursor); 443 } 444 445 /** 446 * Returns the type of the next token. 447 */ 448 public XMLTokenType next() 449 { 450 cursor++; 451 452 // TODO: Check for overflow? 453 454 return getEventType(); 455 } 456 457 public int getAttributeCount() 458 { 459 return token().attributes.size(); 460 } 461 462 public QName getAttributeName(int i) 463 { 464 return token().attributes.get(i).attributeName; 465 } 466 467 public DTDData getDTDInfo() 468 { 469 return token().dtdData; 470 } 471 472 public XMLTokenType getEventType() 473 { 474 return token().type; 475 } 476 477 public String getLocalName() 478 { 479 return token().localName; 480 } 481 482 public Location getLocation() 483 { 484 if (exceptionLocation != null) 485 return exceptionLocation; 486 487 return token().getLocation(); 488 } 489 490 public int getNamespaceCount() 491 { 492 return token().namespaceMappings.size(); 493 } 494 495 public String getNamespacePrefix(int i) 496 { 497 return token().namespaceMappings.get(i).prefix; 498 } 499 500 public String getNamespaceURI() 501 { 502 return token().uri; 503 } 504 505 public String getNamespaceURI(int i) 506 { 507 return token().namespaceMappings.get(i).uri; 508 } 509 510 public String getText() 511 { 512 return token().text; 513 } 514 515 public boolean hasNext() 516 { 517 return cursor < tokens.size() - 1; 518 } 519 520 public String getAttributeValue(int i) 521 { 522 return token().attributes.get(i).value; 523 } 524 525}