81d830265430577b02aeaeaa2365cfc4a887c7ce
[odoo/odoo.git] / openerp / report / report_sxw.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21 from lxml import etree
22 import StringIO
23 import cStringIO
24 import base64
25 from datetime import datetime
26 import os
27 import re
28 import time
29 from interface import report_rml
30 import preprocess
31 import logging
32 import openerp.pooler as pooler
33 import openerp.tools as tools
34 import zipfile
35 import common
36 from openerp.osv.fields import float as float_class, function as function_class
37 from openerp.osv.orm import browse_record
38 from openerp.tools.translate import _
39
40 DT_FORMAT = '%Y-%m-%d'
41 DHM_FORMAT = '%Y-%m-%d %H:%M:%S'
42 HM_FORMAT = '%H:%M:%S'
43
44 rml_parents = {
45     'tr':1,
46     'li':1,
47     'story': 0,
48     'section': 0
49 }
50
51 rml_tag="para"
52
53 sxw_parents = {
54     'table-row': 1,
55     'list-item': 1,
56     'body': 0,
57     'section': 0,
58 }
59
60 html_parents = {
61     'tr' : 1,
62     'body' : 0,
63     'div' : 0
64     }
65 sxw_tag = "p"
66
67 rml2sxw = {
68     'para': 'p',
69 }
70
71 class _format(object):
72     def set_value(self, cr, uid, name, object, field, lang_obj):
73         self.object = object
74         self._field = field
75         self.name = name
76         self.lang_obj = lang_obj
77
78 class _float_format(float, _format):
79     def __init__(self,value):
80         super(_float_format, self).__init__()
81         self.val = value
82
83     def __str__(self):
84         digits = 2
85         if hasattr(self,'_field') and getattr(self._field, 'digits', None):
86             digits = self._field.digits[1]
87         if hasattr(self, 'lang_obj'):
88             return self.lang_obj.format('%.' + str(digits) + 'f', self.name, True)
89         return self.val
90
91 class _int_format(int, _format):
92     def __init__(self,value):
93         super(_int_format, self).__init__()
94         self.val = value and str(value) or str(0)
95
96     def __str__(self):
97         if hasattr(self,'lang_obj'):
98             return self.lang_obj.format('%.d', self.name, True)
99         return self.val
100
101 class _date_format(str, _format):
102     def __init__(self,value):
103         super(_date_format, self).__init__()
104         self.val = value and str(value) or ''
105
106     def __str__(self):
107         if self.val:
108             if getattr(self,'name', None):
109                 date = datetime.strptime(self.name, DT_FORMAT)
110                 return date.strftime(str(self.lang_obj.date_format))
111         return self.val
112
113 class _dttime_format(str, _format):
114     def __init__(self,value):
115         super(_dttime_format, self).__init__()
116         self.val = value and str(value) or ''
117
118     def __str__(self):
119         if self.val and getattr(self,'name', None):
120             return datetime.strptime(self.name, DHM_FORMAT)\
121                    .strftime("%s %s"%(str(self.lang_obj.date_format),
122                                       str(self.lang_obj.time_format)))
123         return self.val
124
125
126 _fields_process = {
127     'float': _float_format,
128     'date': _date_format,
129     'integer': _int_format,
130     'datetime' : _dttime_format
131 }
132
133 #
134 # Context: {'node': node.dom}
135 #
136 class browse_record_list(list):
137     def __init__(self, lst, context):
138         super(browse_record_list, self).__init__(lst)
139         self.context = context
140
141     def __getattr__(self, name):
142         res = browse_record_list([getattr(x,name) for x in self], self.context)
143         return res
144
145     def __str__(self):
146         return "browse_record_list("+str(len(self))+")"
147
148 class rml_parse(object):
149     def __init__(self, cr, uid, name, parents=rml_parents, tag=rml_tag, context=None):
150         if not context:
151             context={}
152         self.cr = cr
153         self.uid = uid
154         self.pool = pooler.get_pool(cr.dbname)
155         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
156         self.localcontext = {
157             'user': user,
158             'setCompany': self.setCompany,
159             'repeatIn': self.repeatIn,
160             'setLang': self.setLang,
161             'setTag': self.setTag,
162             'removeParentNode': self.removeParentNode,
163             'format': self.format,
164             'formatLang': self.formatLang,
165             'lang' : user.company_id.partner_id.lang,
166             'translate' : self._translate,
167             'setHtmlImage' : self.set_html_image,
168             'strip_name' : self._strip_name,
169             'time' : time,
170             # more context members are setup in setCompany() below:
171             #  - company_id
172             #  - logo
173         }
174         self.setCompany(user.company_id)
175         self.localcontext.update(context)
176         self.name = name
177         self._node = None
178         self.parents = parents
179         self.tag = tag
180         self._lang_cache = {}
181         self.lang_dict = {}
182         self.default_lang = {}
183         self.lang_dict_called = False
184         self._transl_regex = re.compile('(\[\[.+?\]\])')
185
186     def setTag(self, oldtag, newtag, attrs=None):
187         return newtag, attrs
188
189     def _ellipsis(self, char, size=100, truncation_str='...'):
190         if len(char) <= size:
191             return char
192         return char[:size-len(truncation_str)] + truncation_str
193
194     def setCompany(self, company_id):
195         if company_id:
196             self.localcontext['company'] = company_id
197             self.localcontext['logo'] = company_id.logo
198             self.rml_header = company_id.rml_header
199             self.rml_header2 = company_id.rml_header2
200             self.rml_header3 = company_id.rml_header3
201             self.logo = company_id.logo
202
203     def _strip_name(self, name, maxlen=50):
204         return self._ellipsis(name, maxlen)
205
206     def format(self, text, oldtag=None):
207         return text.strip()
208
209     def removeParentNode(self, tag=None):
210         raise GeneratorExit('Skip')
211
212     def set_html_image(self,id,model=None,field=None,context=None):
213         if not id :
214             return ''
215         if not model:
216             model = 'ir.attachment'
217         try :
218             id = int(id)
219             res = self.pool.get(model).read(self.cr,self.uid,id)
220             if field :
221                 return res[field]
222             elif model =='ir.attachment' :
223                 return res['datas']
224             else :
225                 return ''
226         except Exception:
227             return ''
228
229     def setLang(self, lang):
230         self.localcontext['lang'] = lang
231         self.lang_dict_called = False
232         for obj in self.objects:
233             obj._context['lang'] = lang
234
235     def _get_lang_dict(self):
236         pool_lang = self.pool.get('res.lang')
237         lang = self.localcontext.get('lang', 'en_US') or 'en_US'
238         lang_ids = pool_lang.search(self.cr,self.uid,[('code','=',lang)])[0]
239         lang_obj = pool_lang.browse(self.cr,self.uid,lang_ids)
240         self.lang_dict.update({'lang_obj':lang_obj,'date_format':lang_obj.date_format,'time_format':lang_obj.time_format})
241         self.default_lang[lang] = self.lang_dict.copy()
242         return True
243
244     def digits_fmt(self, obj=None, f=None, dp=None):
245         digits = self.get_digits(obj, f, dp)
246         return "%%.%df" % (digits, )
247
248     def get_digits(self, obj=None, f=None, dp=None):
249         d = DEFAULT_DIGITS = 2
250         if dp:
251             decimal_precision_obj = self.pool.get('decimal.precision')
252             ids = decimal_precision_obj.search(self.cr, self.uid, [('name', '=', dp)])
253             if ids:
254                 d = decimal_precision_obj.browse(self.cr, self.uid, ids)[0].digits
255         elif obj and f:
256             res_digits = getattr(obj._columns[f], 'digits', lambda x: ((16, DEFAULT_DIGITS)))
257             if isinstance(res_digits, tuple):
258                 d = res_digits[1]
259             else:
260                 d = res_digits(self.cr)[1]
261         elif (hasattr(obj, '_field') and\
262                 isinstance(obj._field, (float_class, function_class)) and\
263                 obj._field.digits):
264                 d = obj._field.digits[1] or DEFAULT_DIGITS
265         return d
266
267     def formatLang(self, value, digits=None, date=False, date_time=False, grouping=True, monetary=False, dp=False, currency_obj=False):
268         """
269             Assuming 'Account' decimal.precision=3:
270                 formatLang(value) -> digits=2 (default)
271                 formatLang(value, digits=4) -> digits=4
272                 formatLang(value, dp='Account') -> digits=3
273                 formatLang(value, digits=5, dp='Account') -> digits=5
274         """
275         if digits is None:
276             if dp:
277                 digits = self.get_digits(dp=dp)
278             else:
279                 digits = self.get_digits(value)
280
281         if isinstance(value, (str, unicode)) and not value:
282             return ''
283
284         if not self.lang_dict_called:
285             self._get_lang_dict()
286             self.lang_dict_called = True
287
288         if date or date_time:
289             if not str(value):
290                 return ''
291
292             date_format = self.lang_dict['date_format']
293             parse_format = DT_FORMAT
294             if date_time:
295                 value=value.split('.')[0]
296                 date_format = date_format + " " + self.lang_dict['time_format']
297                 parse_format = DHM_FORMAT
298             if not isinstance(value, time.struct_time):
299                 return time.strftime(date_format, time.strptime(value, parse_format))
300
301             else:
302                 date = datetime(*value.timetuple()[:6])
303             return date.strftime(date_format)
304
305         res = self.lang_dict['lang_obj'].format('%.' + str(digits) + 'f', value, grouping=grouping, monetary=monetary)
306         res = currency_obj and currency_obj.position_on_report and (currency_obj.position_on_report == 'after' and '%s %s'%(res,currency_obj.symbol) or '%s %s'%(currency_obj.symbol, res) ) or res
307         return res
308
309     def repeatIn(self, lst, name,nodes_parent=False):
310         ret_lst = []
311         for id in lst:
312             ret_lst.append({name:id})
313         return ret_lst
314
315     def _translate(self,text):
316         lang = self.localcontext['lang']
317         if lang and text and not text.isspace():
318             transl_obj = self.pool.get('ir.translation')
319             piece_list = self._transl_regex.split(text)
320             for pn in range(len(piece_list)):
321                 if not self._transl_regex.match(piece_list[pn]):
322                     source_string = piece_list[pn].replace('\n', ' ').strip()
323                     if len(source_string):
324                         translated_string = transl_obj._get_source(self.cr, self.uid, self.name, ('report', 'rml'), lang, source_string)
325                         if translated_string:
326                             piece_list[pn] = piece_list[pn].replace(source_string, translated_string)
327             text = ''.join(piece_list)
328         return text
329
330     def _add_header(self, rml_dom, header='external'):
331         if header=='internal':
332             rml_head =  self.rml_header2
333         elif header=='internal landscape':
334             rml_head =  self.rml_header3
335         else:
336             rml_head =  self.rml_header
337
338         head_dom = etree.XML(rml_head)
339         for tag in head_dom:
340             found = rml_dom.find('.//'+tag.tag)
341             if found is not None and len(found):
342                 if tag.get('position'):
343                     found.append(tag)
344                 else :
345                     found.getparent().replace(found,tag)
346         return True
347
348     def set_context(self, objects, data, ids, report_type = None):
349         self.localcontext['data'] = data
350         self.localcontext['objects'] = objects
351         self.localcontext['digits_fmt'] = self.digits_fmt
352         self.localcontext['get_digits'] = self.get_digits
353         self.datas = data
354         self.ids = ids
355         self.objects = objects
356         if report_type:
357             if report_type=='odt' :
358                 self.localcontext.update({'name_space' :common.odt_namespace})
359             else:
360                 self.localcontext.update({'name_space' :common.sxw_namespace})
361
362         # WARNING: the object[0].exists() call below is slow but necessary because
363         # some broken reporting wizards pass incorrect IDs (e.g. ir.ui.menu ids)
364         if objects and len(objects) == 1 and \
365             objects[0].exists() and 'company_id' in objects[0] and objects[0].company_id:
366             # When we print only one record, we can auto-set the correct
367             # company in the localcontext. For other cases the report
368             # will have to call setCompany() inside the main repeatIn loop.
369             self.setCompany(objects[0].company_id)
370
371 class report_sxw(report_rml, preprocess.report):
372     def __init__(self, name, table, rml=False, parser=rml_parse, header='external', store=False):
373         report_rml.__init__(self, name, table, rml, '')
374         self.name = name
375         self.parser = parser
376         self.header = header
377         self.store = store
378         self.internal_header=False
379         if header=='internal' or header=='internal landscape':
380             self.internal_header=True
381
382     def getObjects(self, cr, uid, ids, context):
383         table_obj = pooler.get_pool(cr.dbname).get(self.table)
384         return table_obj.browse(cr, uid, ids, list_class=browse_record_list, context=context, fields_process=_fields_process)
385
386     def create(self, cr, uid, ids, data, context=None):
387         if self.internal_header:
388             context.update({'internal_header':self.internal_header})
389         pool = pooler.get_pool(cr.dbname)
390         ir_obj = pool.get('ir.actions.report.xml')
391         report_xml_ids = ir_obj.search(cr, uid,
392                 [('report_name', '=', self.name[7:])], context=context)
393         if report_xml_ids:
394             report_xml = ir_obj.browse(cr, uid, report_xml_ids[0], context=context)
395         else:
396             title = ''
397             report_file = tools.file_open(self.tmpl, subdir=None)
398             try:
399                 rml = report_file.read()
400                 report_type= data.get('report_type', 'pdf')
401                 class a(object):
402                     def __init__(self, *args, **argv):
403                         for key,arg in argv.items():
404                             setattr(self, key, arg)
405                 report_xml = a(title=title, report_type=report_type, report_rml_content=rml, name=title, attachment=False, header=self.header)
406             finally:
407                 report_file.close()
408         if report_xml.header:
409             report_xml.header = self.header
410         report_type = report_xml.report_type
411         if report_type in ['sxw','odt']:
412             fnct = self.create_source_odt
413         elif report_type in ['pdf','raw','txt','html']:
414             fnct = self.create_source_pdf
415         elif report_type=='html2html':
416             fnct = self.create_source_html2html
417         elif report_type=='mako2html':
418             fnct = self.create_source_mako2html
419         else:
420             raise NotImplementedError(_('Unknown report type: %s') % report_type)
421         fnct_ret = fnct(cr, uid, ids, data, report_xml, context)
422         if not fnct_ret:
423             return (False,False)
424         return fnct_ret
425
426     def create_source_odt(self, cr, uid, ids, data, report_xml, context=None):
427         return self.create_single_odt(cr, uid, ids, data, report_xml, context or {})
428
429     def create_source_html2html(self, cr, uid, ids, data, report_xml, context=None):
430         return self.create_single_html2html(cr, uid, ids, data, report_xml, context or {})
431
432     def create_source_mako2html(self, cr, uid, ids, data, report_xml, context=None):
433         return self.create_single_mako2html(cr, uid, ids, data, report_xml, context or {})
434
435     def create_source_pdf(self, cr, uid, ids, data, report_xml, context=None):
436         if not context:
437             context={}
438         pool = pooler.get_pool(cr.dbname)
439         attach = report_xml.attachment
440         if attach:
441             objs = self.getObjects(cr, uid, ids, context)
442             results = []
443             for obj in objs:
444                 aname = eval(attach, {'object':obj, 'time':time})
445                 result = False
446                 if report_xml.attachment_use and aname and context.get('attachment_use', True):
447                     aids = pool.get('ir.attachment').search(cr, uid, [('datas_fname','=',aname+'.pdf'),('res_model','=',self.table),('res_id','=',obj.id)])
448                     if aids:
449                         brow_rec = pool.get('ir.attachment').browse(cr, uid, aids[0])
450                         if not brow_rec.datas:
451                             continue
452                         d = base64.decodestring(brow_rec.datas)
453                         results.append((d,'pdf'))
454                         continue
455                 result = self.create_single_pdf(cr, uid, [obj.id], data, report_xml, context)
456                 if not result:
457                     return False
458                 if aname:
459                     try:
460                         name = aname+'.'+result[1]
461                         pool.get('ir.attachment').create(cr, uid, {
462                             'name': aname,
463                             'datas': base64.encodestring(result[0]),
464                             'datas_fname': name,
465                             'res_model': self.table,
466                             'res_id': obj.id,
467                             }, context=context
468                         )
469                     except Exception:
470                         #TODO: should probably raise a proper osv_except instead, shouldn't we? see LP bug #325632
471                         logging.getLogger('report').error('Could not create saved report attachment', exc_info=True)
472                 results.append(result)
473             if results:
474                 if results[0][1]=='pdf':
475                     from pyPdf import PdfFileWriter, PdfFileReader
476                     output = PdfFileWriter()
477                     for r in results:
478                         reader = PdfFileReader(cStringIO.StringIO(r[0]))
479                         for page in range(reader.getNumPages()):
480                             output.addPage(reader.getPage(page))
481                     s = cStringIO.StringIO()
482                     output.write(s)
483                     return s.getvalue(), results[0][1]
484         return self.create_single_pdf(cr, uid, ids, data, report_xml, context)
485
486     def create_single_pdf(self, cr, uid, ids, data, report_xml, context=None):
487         if not context:
488             context={}
489         logo = None
490         context = context.copy()
491         title = report_xml.name
492         rml = report_xml.report_rml_content
493         # if no rml file is found
494         if not rml:
495             return False
496         rml_parser = self.parser(cr, uid, self.name2, context=context)
497         objs = self.getObjects(cr, uid, ids, context)
498         rml_parser.set_context(objs, data, ids, report_xml.report_type)
499         processed_rml = etree.XML(rml)
500         if report_xml.header:
501             rml_parser._add_header(processed_rml, self.header)
502         processed_rml = self.preprocess_rml(processed_rml,report_xml.report_type)
503         if rml_parser.logo:
504             logo = base64.decodestring(rml_parser.logo)
505         create_doc = self.generators[report_xml.report_type]
506         pdf = create_doc(etree.tostring(processed_rml),rml_parser.localcontext,logo,title.encode('utf8'))
507         return (pdf, report_xml.report_type)
508
509     def create_single_odt(self, cr, uid, ids, data, report_xml, context=None):
510         if not context:
511             context={}
512         context = context.copy()
513         report_type = report_xml.report_type
514         context['parents'] = sxw_parents
515
516         # if binary content was passed as unicode, we must
517         # re-encode it as a 8-bit string using the pass-through
518         # 'latin1' encoding, to restore the original byte values.
519         # See also osv.fields.sanitize_binary_value()
520         binary_report_content = report_xml.report_sxw_content.encode("latin1")
521
522         sxw_io = StringIO.StringIO(binary_report_content)
523         sxw_z = zipfile.ZipFile(sxw_io, mode='r')
524         rml = sxw_z.read('content.xml')
525         meta = sxw_z.read('meta.xml')
526         mime_type = sxw_z.read('mimetype')
527         if mime_type == 'application/vnd.sun.xml.writer':
528             mime_type = 'sxw'
529         else :
530             mime_type = 'odt'
531         sxw_z.close()
532
533         rml_parser = self.parser(cr, uid, self.name2, context=context)
534         rml_parser.parents = sxw_parents
535         rml_parser.tag = sxw_tag
536         objs = self.getObjects(cr, uid, ids, context)
537         rml_parser.set_context(objs, data, ids, mime_type)
538
539         rml_dom_meta = node = etree.XML(meta)
540         elements = node.findall(rml_parser.localcontext['name_space']["meta"]+"user-defined")
541         for pe in elements:
542             if pe.get(rml_parser.localcontext['name_space']["meta"]+"name"):
543                 if pe.get(rml_parser.localcontext['name_space']["meta"]+"name") == "Info 3":
544                     pe[0].text=data['id']
545                 if pe.get(rml_parser.localcontext['name_space']["meta"]+"name") == "Info 4":
546                     pe[0].text=data['model']
547         meta = etree.tostring(rml_dom_meta, encoding='utf-8',
548                               xml_declaration=True)
549
550         rml_dom =  etree.XML(rml)
551         elements = []
552         key1 = rml_parser.localcontext['name_space']["text"]+"p"
553         key2 = rml_parser.localcontext['name_space']["text"]+"drop-down"
554         for n in rml_dom.iterdescendants():
555             if n.tag == key1:
556                 elements.append(n)
557         if mime_type == 'odt':
558             for pe in elements:
559                 e = pe.findall(key2)
560                 for de in e:
561                     pp=de.getparent()
562                     if de.text or de.tail:
563                         pe.text = de.text or de.tail
564                     for cnd in de:
565                         if cnd.text or cnd.tail:
566                             if pe.text:
567                                 pe.text +=  cnd.text or cnd.tail
568                             else:
569                                 pe.text =  cnd.text or cnd.tail
570                             pp.remove(de)
571         else:
572             for pe in elements:
573                 e = pe.findall(key2)
574                 for de in e:
575                     pp = de.getparent()
576                     if de.text or de.tail:
577                         pe.text = de.text or de.tail
578                     for cnd in de:
579                         text = cnd.get("{http://openoffice.org/2000/text}value",False)
580                         if text:
581                             if pe.text and text.startswith('[['):
582                                 pe.text +=  text
583                             elif text.startswith('[['):
584                                 pe.text =  text
585                             if de.getparent():
586                                 pp.remove(de)
587
588         rml_dom = self.preprocess_rml(rml_dom, mime_type)
589         create_doc = self.generators[mime_type]
590         odt = etree.tostring(create_doc(rml_dom, rml_parser.localcontext),
591                              encoding='utf-8', xml_declaration=True)
592         sxw_z = zipfile.ZipFile(sxw_io, mode='a')
593         sxw_z.writestr('content.xml', odt)
594         sxw_z.writestr('meta.xml', meta)
595
596         if report_xml.header:
597             #Add corporate header/footer
598             rml_file = tools.file_open(os.path.join('base', 'report', 'corporate_%s_header.xml' % report_type))
599             try:
600                 rml = rml_file.read()
601                 rml_parser = self.parser(cr, uid, self.name2, context=context)
602                 rml_parser.parents = sxw_parents
603                 rml_parser.tag = sxw_tag
604                 objs = self.getObjects(cr, uid, ids, context)
605                 rml_parser.set_context(objs, data, ids, report_xml.report_type)
606                 rml_dom = self.preprocess_rml(etree.XML(rml),report_type)
607                 create_doc = self.generators[report_type]
608                 odt = create_doc(rml_dom,rml_parser.localcontext)
609                 if report_xml.header:
610                     rml_parser._add_header(odt)
611                 odt = etree.tostring(odt, encoding='utf-8',
612                                      xml_declaration=True)
613                 sxw_z.writestr('styles.xml', odt)
614             finally:
615                 rml_file.close()
616         sxw_z.close()
617         final_op = sxw_io.getvalue()
618         sxw_io.close()
619         return (final_op, mime_type)
620
621     def create_single_html2html(self, cr, uid, ids, data, report_xml, context=None):
622         if not context:
623             context = {}
624         context = context.copy()
625         report_type = 'html'
626         context['parents'] = html_parents
627
628         html = report_xml.report_rml_content
629         html_parser = self.parser(cr, uid, self.name2, context=context)
630         html_parser.parents = html_parents
631         html_parser.tag = sxw_tag
632         objs = self.getObjects(cr, uid, ids, context)
633         html_parser.set_context(objs, data, ids, report_type)
634
635         html_dom =  etree.HTML(html)
636         html_dom = self.preprocess_rml(html_dom,'html2html')
637
638         create_doc = self.generators['html2html']
639         html = etree.tostring(create_doc(html_dom, html_parser.localcontext))
640
641         return (html.replace('&amp;','&').replace('&lt;', '<').replace('&gt;', '>').replace('</br>',''), report_type)
642
643     def create_single_mako2html(self, cr, uid, ids, data, report_xml, context=None):
644         mako_html = report_xml.report_rml_content
645         html_parser = self.parser(cr, uid, self.name2, context)
646         objs = self.getObjects(cr, uid, ids, context)
647         html_parser.set_context(objs, data, ids, 'html')
648         create_doc = self.generators['makohtml2html']
649         html = create_doc(mako_html,html_parser.localcontext)
650         return (html,'html')
651