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