2002/08/10 23:36:31
[org.ibex.core.git] / src / org / xwt / HTML.java
1 // Copyright 2002 Adam Megacz, see the COPYING file for licensing [GPL]
2 package org.xwt;
3
4 import org.xwt.util.*;
5 import java.util.*;
6 import java.net.*;
7 import java.io.*;
8
9 /* 
10    While entities are limited to a subset of Unicode characters ,
11    numeric character references can specify any character. Numeric
12    character references may be given in decimal or hexadecimal, though
13    browser support is stronger for decimal references. Decimal
14    references are of the form &#number; while hexadecimal references
15    take the case-insensitive form &#xnumber;. Examples of numeric
16    character references include © or © for the copyright
17    symbol, Α or Α for the Greek capital letter alpha, and
18    ا or ا for the Arabic letter ALEF.
19
20    http://www.htmlhelp.com/reference/html40/entities/special.html
21    http://www.htmlhelp.com/reference/html40/entities/symbols.html
22    http://www.htmlhelp.com/reference/html40/entities/latin1.html
23 */
24
25 /**
26  *   This class parses an InputStream containing HTML and returns it
27  *   as an XWT DOM tree. Each HTML Element is returned as a struct,
28  *   with the following members:
29  *
30  *   Since HTML may have multiple top level elements (unlike XML),
31  *   this class will search all top level elements for one with a tag
32  *   name 'html'. If such a node is found, only it is returned. If no
33  *   top-level element has the tag name 'html', such a node is
34  *   fabricated, and all top level elements become the children of
35  *   that node, which is then returned.
36  */
37 public class HTML {
38
39     // FIXME: fill in
40     private final static String[] bodylessTags = new String[] { "br", "hr", "input", "img", "isindex" };
41
42     /** we keep a char[] around for use by removeRedundantWhitespace() */
43     private static char[] cbuf = null;
44
45     /** we keep a StringBuffer around for use by removeRedundantWhitespace() */
46     private static StringBuffer sbuf = null;
47
48     /** true iff we have encountered an LI more recently than the last OL/UL */
49     private static boolean withinLI = false;
50
51     public static synchronized JSObject parseReader(Reader r) throws IOException {
52         CharStream cs = new CharStream(r);
53         JSObject h = new JSObject();
54
55         withinLI = false;
56         h.put("$name", "html");
57
58         try {
59             while (true) parseBody(cs, h, null);
60         } catch (EOFException e) {
61             // continue until we get an EOFException
62         }
63         
64         Object[] ids = h.getIds();
65         for(int i=0; i<ids.length; i++) {
66             Object el = h.get((String)ids[i]);
67             if (el instanceof JSObject && "html".equals(((JSObject)el).get("$name")))
68                 return (JSObject)el;
69         }
70         
71         return h;
72     }
73
74     /**
75      *  Parses a single element and stores it in <tt>h</tt>. The
76      *  CharStream should be positioned immediately <i>after</i> the
77      *  open bracket.
78      *
79      *  If a close tag not matching this open tag is found, the
80      *  tagname on the close tag will be returned in order to
81      *  facilitate correcting broken HTML. Otherwise, this returns
82      *  null.
83      */
84     private static String parseElement(CharStream cs, JSObject h) throws IOException {
85         // scan element name
86         while(Character.isSpace(cs.peek())) cs.get();
87         String elementName = parseElementName(cs);
88
89         // FIXME: this might not deal correctly with EOFExceptions
90         boolean saveWithinLI = withinLI;
91         if (elementName.equals("li")) {
92             if (withinLI) {
93                 cs.unread(new char[] { '<', 'l', 'i', ' ' });
94                 return "li";
95             } else {
96                 withinLI = true;
97             }
98         } else if (elementName.equals("ol") || elementName.equals("ul")) {
99             withinLI = false;
100         }
101
102         h.put("$name", elementName);
103         if (elementName.equals("!--")) {
104             h.put("0", parseComment(cs));
105             h.put("$numchildren", new Integer(0));
106             return null;
107         }
108
109         // scan attributes
110         while (cs.peek() != '>') {
111             String name = parseAttributeName(cs);
112             if (name.equals("")) break;
113             String value = expandEntities(parseAttributeValue(cs));
114             h.put(name, value);
115         } 
116
117         // eat the close-angle bracket
118         cs.get();
119
120         // bodyless tags return here
121         for(int i=0; i<bodylessTags.length; i++)
122             if (bodylessTags[i].equals(elementName))
123                 return null;
124
125         // scan body
126         String ret = parseBody(cs, h, elementName);
127         withinLI = saveWithinLI;
128         return ret;
129     }
130
131     /**
132      *  Parses the body of an element. The CharStream should be
133      *  positioned at the character immediately after the right
134      *  bracket closing the start-tag
135      */
136     private static String parseBody(CharStream cs, JSObject h, String elementName) throws IOException {
137         String cdata = "";
138         int length = h.get("$numchildren") == null ? 0 : Integer.parseInt(h.get("$numchildren").toString());
139         while(true) {
140             String closetag = null;
141
142             try {
143                 char c = cs.get();
144                 if (c != '<') { cdata += c; continue; }
145                 String expanded = removeRedundantWhitespace(expandEntities(cdata));
146                 if (expanded.length() > 0) {
147                     h.put(String.valueOf(length), expanded);
148                     h.put("$numchildren", new Integer(++length));
149                 }
150                 cdata = "";
151
152             } catch (EOFException e) {
153                 String expanded = removeRedundantWhitespace(expandEntities(cdata));
154                 if (expanded.length() > 0) {
155                     h.put(String.valueOf(length), expanded);
156                     h.put("$numchildren", new Integer(++length));
157                 }
158                 throw e;
159             }
160                 
161             try {
162                 // scan subelement
163                 if (cs.peek() != '/') {
164                     JSObject kid = new JSObject();
165                     closetag = parseElement(cs, kid);
166                     h.put(String.valueOf(length), kid); 
167                     h.put("$numchildren", new Integer(++length));
168                     
169                 // scan close-tag
170                 } else {
171                     cs.get(); // drop the slash
172                     closetag = parseElementName(cs);
173                     while(cs.get() != '>');
174                 }
175             } catch (EOFException e) {
176                 throw e;
177
178             }
179             
180             if (closetag != null)
181                 return closetag.equals(elementName) ? null : closetag;
182         }
183     }
184
185     /** Parses an element name and returns it. The CharStream should
186      *  be positioned at the first character of the name.
187      */
188     private static String parseElementName(CharStream cs) throws IOException {
189         String ret = "";
190         while (cs.peek() != '>' && !Character.isSpace(cs.peek())) ret += cs.get();
191         return ret.toLowerCase();
192     }
193
194     /** Parses an attribute name and returns it. The CharStream should
195      *  be positioned at the first character of the name, possibly
196      *  with intervening whitespace.
197      */
198     private static String parseAttributeName(CharStream cs) throws IOException {
199         while(Character.isSpace(cs.peek())) cs.get();
200         String ret = "";
201         while(!Character.isSpace(cs.peek()) && cs.peek() != '=' && cs.peek() != '>') ret += cs.get();
202         return ret.toLowerCase();
203     }
204
205     /** Parses an attribute value and returns it. The CharStream
206      *  should be positioned at the equals sign, possibly with
207      *  intervening whitespace.
208      */
209     private static String parseAttributeValue(CharStream cs) throws IOException {
210
211         // eat whitespace and equals sign
212         while(Character.isSpace(cs.peek())) cs.get();
213         if (cs.peek() != '=') return "";
214         cs.get();
215         while(Character.isSpace(cs.peek())) cs.get();
216
217         boolean doublequoted = false;
218         boolean singlequoted = false;
219         String ret = "";
220
221         if (cs.peek() == '\"') { doublequoted = true; cs.get(); }
222         else if (cs.peek() == '\'') { singlequoted = true; cs.get(); }
223
224         while(true) {
225             char c = cs.peek();
226             if (!doublequoted && !singlequoted && (Character.isSpace(c) || c == '>')) break;
227             if (singlequoted && c == '\'') { cs.get(); break; }
228             if (doublequoted && c == '\"') { cs.get(); break; }
229             ret += cs.get();
230         }
231         return ret;
232     }
233
234     /** Parses a comment and returns its body. The CharStream should
235      *  be positioned immediately after the &lt;!--
236      */
237     private static String parseComment(CharStream cs) throws IOException {
238         int dashes = 0;
239         String ret = "";
240         while(true) {
241             char c = cs.get();
242             if (c == '>' && dashes == 2) return ret.substring(0, ret.length() - 2);
243             if (c == '-') dashes++;
244             else dashes = 0;
245             ret += c;
246         }
247     }
248
249     /** Expands all SGML entities in string <tt>s</tt> */
250     public static String expandEntities(String s) throws IOException {
251         if (s.indexOf('&') == -1) return s;
252         StringBuffer sb = new StringBuffer();
253         int i=0;
254         int nextamp = 0;
255         while(nextamp != -1) {
256             nextamp = s.indexOf('&', i);
257             sb.append(nextamp == -1 ? s.substring(i) : s.substring(i, nextamp));
258             if (nextamp == -1) break;
259             if (s.regionMatches(nextamp, "&amp;", 0, 5)) {
260                 sb.append("&");
261                 i = nextamp + 5;
262             } else if (s.regionMatches(nextamp, "&gt;", 0, 4)) {
263                 sb.append(">");
264                 i = nextamp + 4;
265             } else if (s.regionMatches(nextamp, "&lt;", 0, 4)) {
266                 sb.append("<");
267                 i = nextamp + 4;
268             } else if (s.regionMatches(nextamp, "&quot;", 0, 6)) {
269                 sb.append("\"");
270                 i = nextamp + 6;
271             } else if (s.regionMatches(nextamp, "&nbsp;", 0, 6)) {
272                 // FIXME: should have a way to indicate this...
273                 sb.append(" ");
274                 i = nextamp + 6;
275             } else {
276                 sb.append("&");
277                 i = nextamp + 1;
278             }
279         }
280         return sb.toString();
281     }
282
283     // FIXME double check this
284     /** removes all redundant whitespace */
285     private static String removeRedundantWhitespace(String s) {
286
287         if (s.indexOf(' ') == -1 && s.indexOf('\n') == -1 && s.indexOf('\t') == -1 && s.indexOf('\r') == -1) return s;
288
289         int len = s.length();
290         if (cbuf == null || cbuf.length < len) {
291             cbuf = new char[len * 2];
292             sbuf = new StringBuffer(len * 2);
293         }
294         sbuf.setLength(0);
295         s.getChars(0, len, cbuf, 0);
296
297         int last = 0;
298         boolean lastWasWhitespace = false;
299         for(int i=0; i<len; i++) {
300             boolean lastlast = lastWasWhitespace;
301             switch(cbuf[i]) {
302             case '\n': case '\r': case '\t':
303                 cbuf[i] = ' ';
304             case ' ':
305                 lastWasWhitespace = true;
306                 break;
307             default:
308                 lastWasWhitespace = false;
309                 break;
310             }
311             if (lastWasWhitespace && lastlast) {
312                 if (last != i) sbuf.append(cbuf, last, i - last);
313                 last = i+1;
314             }
315         }
316             
317         if (last != len) sbuf.append(cbuf, last, len - last);
318         return sbuf.toString().trim();
319     }
320
321     // CharStream /////////////////////////////////////////////////////////////////////
322
323     private static class CharStream extends PushbackReader {
324         public CharStream(Reader r) { super(r, 1024); }
325
326         public char peek() throws IOException {
327             char c = get();
328             unread(c);
329             return c;
330         }
331
332         public char get() throws IOException {
333             int i = read();
334             if (i == -1) throw new EOFException();
335             return (char)i;
336         }
337     }
338
339 }
340