Add remaining Debian artifacts.
[rss2email.git] / html2text.py
1 #!/usr/bin/env python
2 """html2text: Turn HTML into equivalent Markdown-structured text."""
3 __version__ = "3.01"
4 __author__ = "Aaron Swartz (me@aaronsw.com)"
5 __copyright__ = "(C) 2004-2008 Aaron Swartz. GNU GPL 3."
6 __contributors__ = ["Martin 'Joey' Schulze", "Ricardo Reyes", "Kevin Jay North"]
7
8 # TODO:
9 #   Support decoded entities with unifiable.
10
11 try:
12     True
13 except NameError:
14     setattr(__builtins__, 'True', 1)
15     setattr(__builtins__, 'False', 0)
16
17 def has_key(x, y):
18     if hasattr(x, 'has_key'): return x.has_key(y)
19     else: return y in x
20
21 try:
22     import htmlentitydefs
23     import urlparse
24     import HTMLParser
25 except ImportError: #Python3
26     import html.entities as htmlentitydefs
27     import urllib.parse as urlparse
28     import html.parser as HTMLParser
29 try: #Python3
30     import urllib.request as urllib
31 except:
32     import urllib
33 import re, sys, codecs, types
34
35 try: from textwrap import wrap
36 except: pass
37
38 # Use Unicode characters instead of their ascii psuedo-replacements
39 UNICODE_SNOB = 0
40
41 # Put the links after each paragraph instead of at the end.
42 LINKS_EACH_PARAGRAPH = 0
43
44 # Wrap long lines at position. 0 for no wrapping. (Requires Python 2.3.)
45 BODY_WIDTH = 78
46
47 # Don't show internal links (href="#local-anchor") -- corresponding link targets
48 # won't be visible in the plain text file anyway.
49 SKIP_INTERNAL_LINKS = False
50
51 ### Entity Nonsense ###
52
53 def name2cp(k):
54     if k == 'apos': return ord("'")
55     if hasattr(htmlentitydefs, "name2codepoint"): # requires Python 2.3
56         return htmlentitydefs.name2codepoint[k]
57     else:
58         k = htmlentitydefs.entitydefs[k]
59         if k.startswith("&#") and k.endswith(";"): return int(k[2:-1]) # not in latin-1
60         return ord(codecs.latin_1_decode(k)[0])
61
62 unifiable = {'rsquo':"'", 'lsquo':"'", 'rdquo':'"', 'ldquo':'"', 
63 'copy':'(C)', 'mdash':'--', 'nbsp':' ', 'rarr':'->', 'larr':'<-', 'middot':'*',
64 'ndash':'-', 'oelig':'oe', 'aelig':'ae',
65 'agrave':'a', 'aacute':'a', 'acirc':'a', 'atilde':'a', 'auml':'a', 'aring':'a', 
66 'egrave':'e', 'eacute':'e', 'ecirc':'e', 'euml':'e', 
67 'igrave':'i', 'iacute':'i', 'icirc':'i', 'iuml':'i',
68 'ograve':'o', 'oacute':'o', 'ocirc':'o', 'otilde':'o', 'ouml':'o', 
69 'ugrave':'u', 'uacute':'u', 'ucirc':'u', 'uuml':'u'}
70
71 unifiable_n = {}
72
73 for k in unifiable.keys():
74     unifiable_n[name2cp(k)] = unifiable[k]
75
76 def charref(name):
77     if name[0] in ['x','X']:
78         c = int(name[1:], 16)
79     else:
80         c = int(name)
81     
82     if not UNICODE_SNOB and c in unifiable_n.keys():
83         return unifiable_n[c]
84     else:
85         try:
86             return unichr(c)
87         except NameError: #Python3
88             return chr(c)
89
90 def entityref(c):
91     if not UNICODE_SNOB and c in unifiable.keys():
92         return unifiable[c]
93     else:
94         try: name2cp(c)
95         except KeyError: return "&" + c + ';'
96         else:
97             try:
98                 return unichr(name2cp(c))
99             except NameError: #Python3
100                 return chr(name2cp(c))
101
102 def replaceEntities(s):
103     s = s.group(1)
104     if s[0] == "#": 
105         return charref(s[1:])
106     else: return entityref(s)
107
108 r_unescape = re.compile(r"&(#?[xX]?(?:[0-9a-fA-F]+|\w{1,8}));")
109 def unescape(s):
110     return r_unescape.sub(replaceEntities, s)
111
112 ### End Entity Nonsense ###
113
114 def onlywhite(line):
115     """Return true if the line does only consist of whitespace characters."""
116     for c in line:
117         if c is not ' ' and c is not '  ':
118             return c is ' '
119     return line
120
121 def optwrap(text):
122     """Wrap all paragraphs in the provided text."""
123     if not BODY_WIDTH:
124         return text
125     
126     assert wrap, "Requires Python 2.3."
127     result = ''
128     newlines = 0
129     for para in text.split("\n"):
130         if len(para) > 0:
131             if para[0] is not ' ' and para[0] is not '-' and para[0] is not '*':
132                 for line in wrap(para, BODY_WIDTH):
133                     result += line + "\n"
134                 result += "\n"
135                 newlines = 2
136             else:
137                 if not onlywhite(para):
138                     result += para + "\n"
139                     newlines = 1
140         else:
141             if newlines < 2:
142                 result += "\n"
143                 newlines += 1
144     return result
145
146 def hn(tag):
147     if tag[0] == 'h' and len(tag) == 2:
148         try:
149             n = int(tag[1])
150             if n in range(1, 10): return n
151         except ValueError: return 0
152
153 class _html2text(HTMLParser.HTMLParser):
154     def __init__(self, out=None, baseurl=''):
155         HTMLParser.HTMLParser.__init__(self)
156         
157         if out is None: self.out = self.outtextf
158         else: self.out = out
159         try:
160             self.outtext = unicode()
161         except NameError: # Python3
162             self.outtext = str()
163         self.quiet = 0
164         self.p_p = 0
165         self.outcount = 0
166         self.start = 1
167         self.space = 0
168         self.a = []
169         self.astack = []
170         self.acount = 0
171         self.list = []
172         self.blockquote = 0
173         self.pre = 0
174         self.startpre = 0
175         self.lastWasNL = 0
176         self.abbr_title = None # current abbreviation definition
177         self.abbr_data = None # last inner HTML (for abbr being defined)
178         self.abbr_list = {} # stack of abbreviations to write later
179         self.baseurl = baseurl
180     
181     def outtextf(self, s): 
182         self.outtext += s
183     
184     def close(self):
185         HTMLParser.HTMLParser.close(self)
186         
187         self.pbr()
188         self.o('', 0, 'end')
189         
190         return self.outtext
191         
192     def handle_charref(self, c):
193         self.o(charref(c))
194
195     def handle_entityref(self, c):
196         self.o(entityref(c))
197             
198     def handle_starttag(self, tag, attrs):
199         self.handle_tag(tag, attrs, 1)
200     
201     def handle_endtag(self, tag):
202         self.handle_tag(tag, None, 0)
203         
204     def previousIndex(self, attrs):
205         """ returns the index of certain set of attributes (of a link) in the
206             self.a list
207  
208             If the set of attributes is not found, returns None
209         """
210         if not has_key(attrs, 'href'): return None
211         
212         i = -1
213         for a in self.a:
214             i += 1
215             match = 0
216             
217             if has_key(a, 'href') and a['href'] == attrs['href']:
218                 if has_key(a, 'title') or has_key(attrs, 'title'):
219                         if (has_key(a, 'title') and has_key(attrs, 'title') and
220                             a['title'] == attrs['title']):
221                             match = True
222                 else:
223                     match = True
224
225             if match: return i
226
227     def handle_tag(self, tag, attrs, start):
228         #attrs = fixattrs(attrs)
229     
230         if hn(tag):
231             self.p()
232             if start: self.o(hn(tag)*"#" + ' ')
233
234         if tag in ['p', 'div']: self.p()
235         
236         if tag == "br" and start: self.o("  \n")
237
238         if tag == "hr" and start:
239             self.p()
240             self.o("* * *")
241             self.p()
242
243         if tag in ["head", "style", 'script']: 
244             if start: self.quiet += 1
245             else: self.quiet -= 1
246
247         if tag in ["body"]:
248             self.quiet = 0 # sites like 9rules.com never close <head>
249         
250         if tag == "blockquote":
251             if start: 
252                 self.p(); self.o('> ', 0, 1); self.start = 1
253                 self.blockquote += 1
254             else:
255                 self.blockquote -= 1
256                 self.p()
257         
258         if tag in ['em', 'i', 'u']: self.o("_")
259         if tag in ['strong', 'b']: self.o("**")
260         if tag == "code" and not self.pre: self.o('`') #TODO: `` `this` ``
261         if tag == "abbr":
262             if start:
263                 attrsD = {}
264                 for (x, y) in attrs: attrsD[x] = y
265                 attrs = attrsD
266                 
267                 self.abbr_title = None
268                 self.abbr_data = ''
269                 if has_key(attrs, 'title'):
270                     self.abbr_title = attrs['title']
271             else:
272                 if self.abbr_title != None:
273                     self.abbr_list[self.abbr_data] = self.abbr_title
274                     self.abbr_title = None
275                 self.abbr_data = ''
276         
277         if tag == "a":
278             if start:
279                 attrsD = {}
280                 for (x, y) in attrs: attrsD[x] = y
281                 attrs = attrsD
282                 if has_key(attrs, 'href') and not (SKIP_INTERNAL_LINKS and attrs['href'].startswith('#')): 
283                     self.astack.append(attrs)
284                     self.o("[")
285                 else:
286                     self.astack.append(None)
287             else:
288                 if self.astack:
289                     a = self.astack.pop()
290                     if a:
291                         i = self.previousIndex(a)
292                         if i is not None:
293                             a = self.a[i]
294                         else:
295                             self.acount += 1
296                             a['count'] = self.acount
297                             a['outcount'] = self.outcount
298                             self.a.append(a)
299                         self.o("][" + str(a['count']) + "]")
300         
301         if tag == "img" and start:
302             attrsD = {}
303             for (x, y) in attrs: attrsD[x] = y
304             attrs = attrsD
305             if has_key(attrs, 'src'):
306                 attrs['href'] = attrs['src']
307                 alt = attrs.get('alt', '')
308                 alt = re.sub('\n', ' ', alt)
309                 i = self.previousIndex(attrs)
310                 if i is not None:
311                     attrs = self.a[i]
312                 else:
313                     self.acount += 1
314                     attrs['count'] = self.acount
315                     attrs['outcount'] = self.outcount
316                     self.a.append(attrs)
317                 self.o("![")
318                 self.o(alt)
319                 self.o("]["+ str(attrs['count']) +"]")
320         
321         if tag == 'dl' and start: self.p()
322         if tag == 'dt' and not start: self.pbr()
323         if tag == 'dd' and start: self.o('    ')
324         if tag == 'dd' and not start: self.pbr()
325         
326         if tag in ["ol", "ul"]:
327             if start:
328                 self.list.append({'name':tag, 'num':0})
329             else:
330                 if self.list: self.list.pop()
331             
332             self.p()
333         
334         if tag == 'li':
335             if start:
336                 self.pbr()
337                 if self.list: li = self.list[-1]
338                 else: li = {'name':'ul', 'num':0}
339                 self.o("  "*len(self.list)) #TODO: line up <ol><li>s > 9 correctly.
340                 if li['name'] == "ul": self.o("* ")
341                 elif li['name'] == "ol":
342                     li['num'] += 1
343                     self.o(str(li['num'])+". ")
344                 self.start = 1
345             else:
346                 self.pbr()
347         
348         if tag in ["table", "tr"] and start: self.p()
349         if tag == 'td': self.pbr()
350         
351         if tag == "pre":
352             if start:
353                 self.startpre = 1
354                 self.pre = 1
355             else:
356                 self.pre = 0
357             self.p()
358             
359     def pbr(self):
360         if self.p_p == 0: self.p_p = 1
361
362     def p(self): self.p_p = 2
363     
364     def o(self, data, puredata=0, force=0):
365         if self.abbr_data is not None: self.abbr_data += data
366         
367         if not self.quiet: 
368             if puredata and not self.pre:
369                 data = re.sub('\s+', ' ', data)
370                 if data and data[0] == ' ':
371                     self.space = 1
372                     data = data[1:]
373             if not data and not force: return
374             
375             if self.startpre:
376                 #self.out(" :") #TODO: not output when already one there
377                 self.startpre = 0
378             
379             bq = (">" * self.blockquote)
380             if not (force and data and data[0] == ">") and self.blockquote: bq += " "
381             
382             if self.pre:
383                 bq += "    "
384                 data = data.replace("\n", "\n"+bq)
385             
386             if self.start:
387                 self.space = 0
388                 self.p_p = 0
389                 self.start = 0
390
391             if force == 'end':
392                 # It's the end.
393                 self.p_p = 0
394                 self.out("\n")
395                 self.space = 0
396
397
398             if self.p_p:
399                 self.out(('\n'+bq)*self.p_p)
400                 self.space = 0
401                 
402             if self.space:
403                 if not self.lastWasNL: self.out(' ')
404                 self.space = 0
405
406             if self.a and ((self.p_p == 2 and LINKS_EACH_PARAGRAPH) or force == "end"):
407                 if force == "end": self.out("\n")
408
409                 newa = []
410                 for link in self.a:
411                     if self.outcount > link['outcount']:
412                         self.out("   ["+ str(link['count']) +"]: " + urlparse.urljoin(self.baseurl, link['href'])) 
413                         if has_key(link, 'title'): self.out(" ("+link['title']+")")
414                         self.out("\n")
415                     else:
416                         newa.append(link)
417
418                 if self.a != newa: self.out("\n") # Don't need an extra line when nothing was done.
419
420                 self.a = newa
421             
422             if self.abbr_list and force == "end":
423                 for abbr, definition in self.abbr_list.items():
424                     self.out("  *[" + abbr + "]: " + definition + "\n")
425
426             self.p_p = 0
427             self.out(data)
428             self.lastWasNL = data and data[-1] == '\n'
429             self.outcount += 1
430
431     def handle_data(self, data):
432         if r'\/script>' in data: self.quiet -= 1
433         self.o(data, 1)
434     
435     def unknown_decl(self, data): pass
436
437 def wrapwrite(text): sys.stdout.write(text)
438
439 def html2text_file(html, out=wrapwrite, baseurl=''):
440     h = _html2text(out, baseurl)
441     h.feed(html)
442     h.feed("")
443     return h.close()
444
445 def html2text(html, baseurl=''):
446     return optwrap(html2text_file(html, None, baseurl))
447
448 if __name__ == "__main__":
449     baseurl = ''
450     if sys.argv[1:]:
451         arg = sys.argv[1]
452         if arg.startswith('http://') or arg.startswith('https://'):
453             baseurl = arg
454             j = urllib.urlopen(baseurl)
455             try:
456                 from feedparser import _getCharacterEncoding as enc
457             except ImportError:
458                    enc = lambda x, y: ('utf-8', 1)
459             text = j.read()
460             encoding = enc(j.headers, text)[0]
461             if encoding == 'us-ascii': encoding = 'utf-8'
462             data = text.decode(encoding)
463
464         else:
465             encoding = 'utf8'
466             if len(sys.argv) > 2:
467                 encoding = sys.argv[2]
468             try: #Python3
469                 data = open(arg, 'r', encoding=encoding).read()
470             except TypeError:
471                 data = open(arg, 'r').read().decode(encoding)
472     else:
473         data = sys.stdin.read()
474     wrapwrite(html2text(data, baseurl))