Update support for wrregion and wrregioncache.
[philipp/winterrodeln/wrpylib.git] / wrpylib / mwmarkup.py
1 #!/usr/bin/python2.7
2 # -*- coding: iso-8859-15 -*-
3 # $Id$
4 # $HeadURL$
5 """This module contains general functions that help parsing the mediawiki markup.
6 I looked for an already existing MediaWiki parser in Python but I didn't find anything 
7 that convinced me. However, here are the links:
8
9 * py-wikimarkup https://github.com/dcramer/py-wikimarkup
10 * mwlib http://code.pediapress.com/wiki/wiki
11 """
12 import re
13 import xml.etree.ElementTree
14
15
16 class ParseError(RuntimeError):
17     """Exception used by some of the functions"""
18     pass
19
20
21 def find_template(wikitext, template_title):
22     """Returns the tuple (start, end) of the first occurence of the template '{{template ...}} within wikitext'.
23     (None, None) is returned if the template is not found.
24     If you are sure that the wikitext contains the template, the template could be extracted like follows:
25
26     >>> wikitext = u'This is a {{Color|red|red text}} template.'
27     >>> start, end = find_template(wikitext, u'Color')
28     >>> print wikitext[start:end]
29     {{Color|red|red text}}
30
31     or just:
32
33     >>> print wikitext.__getslice__(*find_template(wikitext, u'Color'))
34     {{Color|red|red text}}
35
36     The search is done with regular expression. It gives wrong results when parsing a template
37     containing the characters "}}"
38
39     :param wikitext: The text (preferalbe unicode) that has the template in it.
40     :param template_title: The page title of the template with or without namespace (but as in the wikitext).
41     :return: 
42         (start, end) of the first occurence with start >= 0 and end > start.
43         (None, None) if the template is not found.
44     """ 
45     match = re.search(u"\{\{" + template_title + "\s*(\|[^\}]*)?\}\}", wikitext,  re.DOTALL)
46     if match is None: return None, None
47     return match.start(), match.end()
48
49
50 def split_template(template):
51     """Takes a template, like u'{{Color|red|text=Any text}}' and translates it to a Python tuple
52     (template_title, parameters) where parameters is a Python dictionary {u'1': u'red', u'text'=u'Any text'}.
53     Anonymous parameters get integer keys (converted to unicode) starting with 1 
54     like in MediaWiki, named parameters are unicode strings.
55     Whitespace is stripped.
56     If an unexpected format is encountered, a ValueError is raised."""
57     if not template.startswith(u'{{'): raise ValueError(u'Template does not start with "{{"')
58     if not template.endswith(u'}}'): raise ValueError(u'Template does not end with "}}"')
59     parts = template[2:-2].split(u'|')
60
61     # template name
62     template_title = parts[0].strip()
63     if len(template_title) == 0: raise ValueError(u'Empty template tilte.')
64     del parts[0]
65
66     # anonymous parameters
67     params = {} # result dictionary
68     param_num = 1
69     while len(parts) > 0:
70         equalsign_pos = parts[0].find(u'=')
71         if equalsign_pos >= 0: break # named parameter
72         params[unicode(param_num)] = parts[0].strip()
73         del parts[0]
74         param_num += 1
75
76     # named or numbered parameters
77     while len(parts) > 0:
78         equalsign_pos = parts[0].find(u'=')
79         if equalsign_pos < 0: raise ValueError(u'Anonymous parameter after named parameter.')
80         key, sep, value = parts[0].partition(u'=')
81         key = key.strip()
82         if len(key) == 0: raise ValueError(u'Empty key.')
83         if params.has_key(key): raise ValueError(u'Duplicate key: "{0}"'.format(key))
84         params[key] = value.strip()
85         del parts[0]
86
87     return template_title, params
88
89
90 def create_template(template_title, anonym_params=[], named_param_keys=[], named_param_values=[], as_table=False, as_table_keylen=None):
91     """Formats a MediaWiki template.
92     :param template_title: Unicode string with the template name
93     :param anonym_params: list with parameters without keys
94     :param named_param_keys: list with keys of named parameters
95     :param named_param_values: list with values of named parameters, corresponding to named_param_keys.
96     :param as_table: formats the returned template in one row for each parameter
97     :param as_table_keylen: length of the key field. None for "automatic".
98     :return: unicode template"""
99     pipe_char, equal_char, end_char = (u'\n| ', u' = ', u'\n}}') if as_table else (u'|', u'=', u'}}')
100     parts = [u"{{" + template_title]
101     parts += anonym_params
102     if as_table and as_table_keylen is None:
103         as_table_keylen = max([len(k) for k in named_param_keys])
104     for i in xrange(len(named_param_keys)):
105         key = named_param_keys[i]
106         if as_table: 
107             key = key.ljust(as_table_keylen)
108             parts.append((key + equal_char + named_param_values[i]).rstrip())
109         else:
110             parts.append(key + equal_char + named_param_values[i])
111     return pipe_char.join(parts) + end_char
112
113
114 def find_tag(wikitext, tagname, pos=0):
115     """Returns position information of the first occurence of the tag '<tag ...>...</tag>'
116     or '<tag ... />'.
117     If you are sure that the wikitext contains the tag, the tag could be extracted like follows:
118
119     >>> wikitext = u'This is a <tag>mytag</tag> tag.'
120     >>> start, content, endtag, end = find_template(wikitext, u'tag')
121     >>> print wikitext[start:end]
122     <tag>mytag</tag>
123
124     :param wikitext: The text (preferalbe unicode) that has the template in it.
125     :param tagname: Name of the tag, e.g. u'tag' for <tag>.
126     :param pos: position within wikitext to start searching the tag.
127     :return:
128         (start, content, endtag, end). start is the position of '<' of the tag,
129         content is the beginning of the content (after '>'), enttag is the
130         beginning of the end tag ('</') and end is one position after the end tag.
131         For single tags, (start, None, None, end) is returned.
132         If the tag is not found (or only the start tag is present,
133         (None, None, None, None) is returned.
134     """
135     # Find start tag
136     regexp_starttag = re.compile(u"<{0}.*?(/?)>".format(tagname), re.DOTALL)
137     match_starttag = regexp_starttag.search(wikitext, pos)
138     if match_starttag is None:
139         return None, None, None, None
140
141     # does the tag have content?
142     if len(match_starttag.group(1)) == 1: # group(1) is either '' or '/'.
143         # single tag
144         return match_starttag.start(), None, None, match_starttag.end()
145
146     # tag with content
147     regexp_endtag = re.compile(u'</{0}>'.format(tagname), re.DOTALL)
148     match_endtag = regexp_endtag.search(wikitext, match_starttag.end())
149     if match_endtag is None:
150         # No closing tag - error in wikitext
151         return None, None, None, None
152     return match_starttag.start(), match_starttag.end(), match_endtag.start(), match_endtag.end()
153
154
155 def parse_googlemap(wikitext):
156     """Parses the (unicode) u'<googlemap ...>content</googlemap>' of the googlemap extension.
157     If wikitext does not contain the <googlemap> tag or if the <googlemap> tag contains
158     invalid formatted lines, a ParseError is raised.
159     Use find_tag(wikitext, 'googlemap') to find the googlemap tag within an arbitrary
160     wikitext before using this function.
161
162     :param wikitext: wikitext containing the template. Example:
163
164     wikitext = '''
165     <googlemap version="0.9" lat="47.113291" lon="11.272337" zoom="15">
166     (Parkplatz)47.114958,11.266026
167     Parkplatz
168     
169     (Gasthaus) 47.114715, 11.266262, Alt Bärnbad (Gasthaus)
170     6#FF014E9A
171     47.114715,11.266262
172     47.114135,11.268381
173     47.113421,11.269322
174     47.11277,11.269979
175     47.112408,11.271119
176     </googlemap>
177     '''
178     :returns: The tuple (attributes, coords, paths) is returned.
179         attributes is a dict that contains the attribues that are present
180         (e.g. lon, lat, zoom, width, height) converted to float (lon, lat) or int.
181         coords is a list of (lon, lat, symbol, title) tuples.
182         paths is a list of (style, coords) tuples.
183         coords is again a list of (lon, lat, symbol, title) tuples."""
184
185     def is_coord(line):
186         """Returns True if the line contains a coordinate."""
187         match = re.search('[0-9]{1,2}\.[0-9]+, ?[0-9]{1,2}\.[0-9]+', line)
188         return not match is None
189
190     def is_path(line):
191         """Returns True if the line contains a path style definition."""
192         match = re.match('[0-9]#[0-9a-fA-F]{8}', line)
193         return not match is None
194
195     def parse_coord(line):
196         """Returns (lon, lat, symbol, title). If symbol or text is not present, None is returned."""
197         match = re.match(u'\(([^)]+)\) ?([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+), ?(.*)', line)
198         if not match is None: return (float(match.group(3)), float(match.group(2)), match.group(1), match.group(4))
199         match = re.match(u'\(([^)]+)\) ?([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+)', line)
200         if not match is None: return (float(match.group(3)), float(match.group(2)), match.group(1), None)
201         match = re.match(u'([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+), ?(.*)', line)
202         if not match is None: return (float(match.group(2)), float(match.group(1)), None, match.group(3))
203         match = re.match(u'([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+)', line)
204         if not match is None: return (float(match.group(2)), float(match.group(1)), None, None)
205         return ParseError(u'Could not parse line ' + line)
206
207     start, content, endtag, end = find_tag(wikitext, 'googlemap')
208     if start is None:
209         raise ParseError(u'<googlemap> tag not found.')
210     if content is None:
211         xml_only = wikitext[start:endtag]
212     else:
213         xml_only = wikitext[start:content]+wikitext[endtag:end]
214         
215     try:
216         gm = xml.etree.ElementTree.XML(xml_only.encode('UTF8'))
217     except xml.etree.ElementTree.ParseError as e:
218         row, column = e.position
219         raise ParseError(u"XML parse error in <googlemap ...>.")
220
221     # parse attributes
222     attributes = {}
223     try:
224         for key in ['lon', 'lat']:
225             if gm.get(key) is not None:
226                 attributes[key] = float(gm.get(key))
227         for key in ['zoom', 'width', 'height']:
228             if gm.get(key) is not None:
229                 attributes[key] = int(gm.get(key))
230     except ValueError as error:
231         raise ParseError(u'Error at parsing attribute {0} of <googlemap>: {1}'.format(key, unicode(error)))
232
233     # parse points and lines
234     coords = []
235     paths = []
236     lines = wikitext[content:endtag].split("\n")
237     i = 0
238     while i < len(lines):
239         line = lines[i].strip()
240         i += 1
241
242         # Skip whitespace
243         if len(line) == 0: continue
244
245         # Handle a path
246         if is_path(line):
247             match = re.match(u'([0-9]#[0-9a-fA-F]{8})', line)
248             style =  match.group(1)
249             local_coords = []
250             while i < len(lines):
251                 line = lines[i].strip()
252                 i += 1
253                 if is_path(line):
254                     i -= 1
255                     break
256                 if is_coord(line):
257                     lon, lat, symbol, title = parse_coord(line)
258                     local_coords.append((lon, lat, symbol, title))
259             paths.append((style, local_coords))
260             continue
261
262         # Handle a coordinate
263         if is_coord(line):
264             lon, lat, symbol, title = parse_coord(line)
265             while i < len(lines):
266                 line = lines[i].strip()
267                 i += 1
268                 if is_path(line) or is_coord(line):
269                     i -= 1
270                     break
271                 if len(line) > 0 and title is None: title = line
272             coords.append((lon, lat, symbol, title))
273             continue
274
275         raise ParseError(u'Unknown line syntax: ' + line)
276
277     return (attributes, coords, paths)
278