slugify anchors, fix some minor html issues
[librarian.git] / librarian / pyhtml.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of Librarian, licensed under GNU Affero GPLv3 or later.
4 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 #
6 from lxml import etree
7 from librarian import IOFile, RDFNS, DCNS, Format
8 from xmlutils import Xmill, tag, tagged, ifoption, tag_open_close
9 from librarian import functions
10 import re
11 import random
12 from copy import deepcopy
13
14 IMAGE_THUMB_WIDTH = 300
15
16 try:
17     from fnpdjango.utils.text.slughifi import slughifi
18     def naglowek_to_anchor(naglowek):
19         return slughifi(naglowek.text)
20 except ImportError:
21     from urllib import quote
22     def naglowek_to_anchor(naglowek):
23         return quote(re.sub(r" +", " ", naglowek.text.strip()))
24     
25     
26
27 class EduModule(Xmill):
28     def __init__(self, options=None):
29         super(EduModule, self).__init__(options)
30         self.activity_counter = 0
31         self.exercise_counter = 0
32
33         # text filters
34         def swap_endlines(txt):
35             if self.options['strofa']:
36                 txt = txt.replace("/\n", "<br/>\n")
37             return txt
38         self.register_text_filter(functions.substitute_entities)
39         self.register_text_filter(swap_endlines)
40
41     @tagged('div', 'stanza')
42     def handle_strofa(self, element):
43         self.options = {'strofa': True}
44         return "", ""
45
46     def handle_powiesc(self, element):
47         return u"""
48 <div class="module" id="book-text">
49 <!-- <span class="teacher-toggle">
50   <input type="checkbox" name="teacher-toggle" id="teacher-toggle"/>
51   <label for="teacher-toggle">Pokaż treść dla nauczyciela</label>
52  </span>-->
53
54 """, u"</div>"
55
56     handle_autor_utworu = tag("span", "author")
57     handle_dzielo_nadrzedne = tag("span", "collection")
58     handle_podtytul = tag("span", "subtitle")
59     handle_naglowek_akt = handle_naglowek_czesc = handle_srodtytul = tag("h2")
60     handle_naglowek_scena = tag('h2')
61     handle_naglowek_osoba = handle_naglowek_podrozdzial = tag('h3')
62     handle_akap = handle_akap_dialog = handle_akap_cd = tag('p', 'paragraph')
63
64     handle_wyroznienie = tag('em')
65     handle_tytul_dziela = tag('em', 'title')
66     handle_slowo_obce = tag('em', 'foreign')
67
68     def handle_nazwa_utworu(self, element):
69         toc = []
70         for naglowek in element.getparent().findall('.//naglowek_rozdzial'):
71             a = etree.Element("a")
72             a.attrib["href"] = "#" + naglowek_to_anchor(naglowek)
73             a.text = naglowek.text
74             atxt = etree.tostring(a, encoding=unicode)
75             toc.append("<li>%s</li>" % atxt)
76         toc = "<ul class='toc'>%s</ul>" % "".join(toc)
77         add_header = "Lekcja: " if self.options['wldoc'].book_info.type in ('course', 'synthetic') else ''
78         return "<h1 class='title' id='top'>%s" % add_header, "</h1>" + toc
79
80     def handle_naglowek_rozdzial(self, element):
81         return_to_top = u"<a href='#top' class='top-link'>wróć do spisu treści</a>"
82         pre, post = tag_open_close("h2", id=naglowek_to_anchor(element))
83         return return_to_top + pre, post
84
85     def handle_uwaga(self, _e):
86         return None
87
88     def handle_aktywnosc(self, element):
89         self.activity_counter += 1
90         self.options = {
91             'activity': True,
92             'activity_counter': self.activity_counter,
93             }
94         submill = EduModule(dict(self.options.items() + {'sub_gen': True}.items()))
95
96         opis = submill.generate(element.xpath('opis')[0])
97
98         n = element.xpath('wskazowki')
99         if n: wskazowki = submill.generate(n[0])
100
101         else: wskazowki = ''
102         n = element.xpath('pomoce')
103
104         if n: pomoce = submill.generate(n[0])
105         else: pomoce = ''
106
107         forma = ''.join(element.xpath('forma/text()'))
108
109         czas = ''.join(element.xpath('czas/text()'))
110
111         counter = self.activity_counter
112
113         return u"""
114 <div class="activity">
115  <div class="text">
116   <span class="act_counter">%(counter)d.</span>
117   %(opis)s""" % locals(), \
118 u"""%(wskazowki)s
119  </div>
120  <aside class="info">
121   <section class="infobox time"><h1>Czas</h1><p>%(czas)s min</p></section>
122   <section class="infobox kind"><h1>Metoda</h1><p>%(forma)s</p></section>
123   %(pomoce)s
124  </aside>
125  <div class="clearboth"></div>
126 </div>
127 """ % locals()
128
129     handle_opis = ifoption(sub_gen=True)(tag('div', 'description'))
130     handle_wskazowki = ifoption(sub_gen=True)(tag('div', ('hints', 'teacher')))
131
132     @ifoption(sub_gen=True)
133     @tagged('section', 'infobox materials')
134     def handle_pomoce(self, _):
135         return """<h1>Pomoce</h1>""", ""
136
137     def handle_czas(self, *_):
138         return
139
140     def handle_forma(self, *_):
141         return
142
143     def handle_cwiczenie(self, element):
144         exercise_handlers = {
145             'wybor': Wybor,
146             'uporzadkuj': Uporzadkuj,
147             'luki': Luki,
148             'zastap': Zastap,
149             'przyporzadkuj': Przyporzadkuj,
150             'prawdafalsz': PrawdaFalsz
151             }
152
153         typ = element.attrib['typ']
154         self.exercise_counter += 1
155         self.options = {'exercise_counter': self.exercise_counter}
156         handler = exercise_handlers[typ](self.options)
157         return handler.generate(element)
158
159     # Lists
160     def handle_lista(self, element, attrs={}):
161         ltype = element.attrib.get('typ', 'punkt')
162         if not element.findall("punkt"):
163             if ltype == 'czytelnia':
164                 return '<p>W przygotowaniu.</p>'
165             else:
166                 return None
167         if ltype == 'slowniczek':
168             surl = element.attrib.get('src', None)
169             if surl is None:
170                 # print '** missing src on <slowniczek>, setting default'
171                 surl = 'http://edukacjamedialna.edu.pl/lekcje/slowniczek/'
172             sxml = None
173             if surl:
174                 sxml = etree.fromstring(self.options['provider'].by_uri(surl).get_string())
175             self.options = {'slowniczek': True, 'slowniczek_xml': sxml }
176             pre, post = '<div class="slowniczek">', '</div>'
177             if self.options['wldoc'].book_info.url.slug != 'slowniczek':
178                 post += u'<p class="see-more"><a href="%s">Zobacz cały słowniczek.</a></p>' % surl
179             return pre, post
180
181         listtag = {'num': 'ol',
182                'punkt': 'ul',
183                'alfa': 'ul',
184                'czytelnia': 'ul'}[ltype]
185
186         classes = attrs.get('class', '')
187         if classes: del attrs['class']
188
189         attrs_s = ' '.join(['%s="%s"' % kv for kv in attrs.items()])
190         if attrs_s: attrs_s = ' ' + attrs_s
191
192         return '<%s class="lista %s %s"%s>' % (listtag, ltype, classes, attrs_s), '</%s>' % listtag
193
194     def handle_punkt(self, element):
195         if self.options['slowniczek']:
196             return '<dl>', '</dl>'
197         else:
198             return '<li>', '</li>'
199
200     def handle_definiendum(self, element):
201         nxt = element.getnext()
202         definiens_s = ''
203
204         if not element.text:
205             print "!! Empty <definiendum/>"
206             return None
207
208         # let's pull definiens from another document
209         if self.options['slowniczek_xml'] is not None and (nxt is None or nxt.tag != 'definiens'):
210             sxml = self.options['slowniczek_xml']
211             defloc = sxml.xpath("//definiendum[text()='%s']" % element.text)
212             if defloc:
213                 definiens = defloc[0].getnext()
214                 if definiens.tag == 'definiens':
215                     subgen = EduModule(self.options)
216                     definiens_s = subgen.generate(definiens)
217             else:
218                 print '!! Missing definiendum in source:', element.text
219
220         return u"<dt>", u"</dt>" + definiens_s
221
222     def handle_definiens(self, element):
223         return u"<dd>", u"</dd>"
224
225     def handle_podpis(self, element):
226         return u"""<div class="caption">""", u"</div>"
227
228     def handle_tabela(self, element):
229         has_frames = int(element.attrib.get("ramki", "0"))
230         if has_frames: frames_c = "framed"
231         else: frames_c = ""
232         return u"""<table class="%s">""" % frames_c, u"</table>"
233
234     def handle_wiersz(self, element):
235         return u"<tr>", u"</tr>"
236
237     def handle_kol(self, element):
238         return u"<td>", u"</td>"
239
240     def handle_rdf__RDF(self, _):
241         # ustal w opcjach  rzeczy :D
242         return
243
244     def handle_link(self, element):
245         if 'url' in element.attrib:
246             return tag('a', href=element.attrib['url'])(self, element)
247         elif 'material' in element.attrib:
248             material_err = u' [BRAKUJĄCY MATERIAŁ]'
249             slug = element.attrib['material']
250             make_url = lambda f: self.options['urlmapper'] \
251               .url_for_material(slug, f)
252
253             if 'format' in element.attrib:
254                 formats = re.split(r"[, ]+",
255                                element.attrib['format'])
256             else:
257                 formats = [None]
258
259             formats = self.options['urlmapper'].materials(slug)
260
261             try:
262                 def_href = make_url(formats[0][0])
263                 def_err = u""
264             except (IndexError, self.options['urlmapper'].MaterialNotFound):
265                 def_err = material_err
266                 def_href = u""
267             fmt_links = []
268             for f in formats[1:]:
269                 try:
270                     fmt_links.append(u'<a href="%s">%s</a>' % (make_url(f[0]), f[0].upper()))
271                 except self.options['urlmapper'].MaterialNotFound:
272                     fmt_links.append(u'<a>%s%s</a>' % (f[0].upper(), material_err))
273             more_links = u' (%s)' % u', '.join(fmt_links) if fmt_links else u''
274
275             return u"<a href='%s'>" % def_href, u'%s</a>%s' % (def_err, more_links)
276
277     def handle_obraz(self, element):
278         name = element.attrib.get('nazwa', '').strip()
279         if not name:
280             print '!! <obraz> missing "nazwa"'
281             return
282         alt = element.attrib.get('alt', '')
283         if not alt:
284             print '** <obraz> missing "alt"'
285         slug, ext = name.rsplit('.', 1)
286         url = self.options['urlmapper'].url_for_image(slug, ext)
287         thumb_url = self.options['urlmapper'].url_for_image(slug, ext, IMAGE_THUMB_WIDTH)
288         e = etree.Element("a", attrib={"href": url, "class": "image"})
289         e.append(etree.Element("img", attrib={"src": thumb_url, "alt": alt,
290                     "width": str(IMAGE_THUMB_WIDTH)}))
291         return etree.tostring(e, encoding=unicode), u""
292
293     def handle_video(self, element):
294         url = element.attrib.get('url')
295         if not url:
296             print '!! <video> missing url'
297             return
298         m = re.match(r'(?:https?://)?(?:www.)?youtube.com/watch\?(?:.*&)?v=([^&]+)(?:$|&)', url)
299         if not m:
300             print '!! unknown <video> url scheme:', url
301             return
302         return """<iframe width="630" height="384" src="http://www.youtube.com/embed/%s"
303             frameborder="0" allowfullscreen></iframe>""" % m.group(1), ""
304
305
306 class Exercise(EduModule):
307     INSTRUCTION = ""
308     def __init__(self, *args, **kw):
309         self.question_counter = 0
310         super(Exercise, self).__init__(*args, **kw)
311         self.instruction_printed = False
312
313     @tagged('div', 'description')
314     def handle_opis(self, element):
315         return "", self.get_instruction()
316
317     def handle_rozw_kom(self, element):
318         return u"""<div style="display:none" class="comment">""", u"""</div>"""
319
320     def handle_cwiczenie(self, element):
321         self.options = {'exercise': element.attrib['typ']}
322         self.question_counter = 0
323         self.piece_counter = 0
324
325         pre = u"""
326 <div class="exercise %(typ)s" data-type="%(typ)s">
327 <form action="#" method="POST">
328 <h3>Zadanie %(exercies_counter)d</h3>
329 <div class="buttons">
330 <span class="message"></span>
331 <input type="button" class="check" value="sprawdź"/>
332 <input type="button" class="retry" style="display:none" value="spróbuj ponownie"/>
333 <input type="button" class="solutions" value="pokaż rozwiązanie"/>
334 <input type="button" class="reset" value="reset"/>
335 </div>
336
337 """ % {'exercies_counter': self.options['exercise_counter'], 'typ': element.attrib['typ']}
338         post = u"""
339 <div class="buttons">
340 <span class="message"></span>
341 <input type="button" class="check" value="sprawdź"/>
342 <input type="button" class="retry" style="display:none" value="spróbuj ponownie"/>
343 <input type="button" class="solutions" value="pokaż rozwiązanie"/>
344 <input type="button" class="reset" value="reset"/>
345 </div>
346 </form>
347 </div>
348 """
349         # Add a single <pytanie> tag if it's not there
350         if not element.xpath(".//pytanie"):
351             qpre, qpost = self.handle_pytanie(element)
352             pre = pre + qpre
353             post = qpost + post
354         return pre, post
355
356     def handle_pytanie(self, element):
357         """This will handle <cwiczenie> element, when there is no <pytanie>
358         """
359         add_class = ""
360         self.question_counter += 1
361         self.piece_counter = 0
362         solution = element.attrib.get('rozw', None)
363         if solution: solution_s = ' data-solution="%s"' % solution
364         else: solution_s = ''
365
366         handles = element.attrib.get('uchwyty', None)
367         if handles:
368             add_class += ' handles handles-%s' % handles
369             self.options = {'handles': handles}
370
371         minimum = element.attrib.get('min', None)
372         if minimum: minimum_s = ' data-minimum="%d"' % int(minimum)
373         else: minimum_s = ''
374
375         return '<div class="question%s" data-no="%d" %s>' %\
376             (add_class, self.question_counter, solution_s + minimum_s), \
377             "</div>"
378
379     def get_instruction(self):
380         if not self.instruction_printed:
381             self.instruction_printed = True
382             if self.INSTRUCTION:
383                 return u'<span class="instruction">%s</span>' % self.INSTRUCTION
384             else:
385                 return ""
386         else:
387             return ""
388
389
390
391 class Wybor(Exercise):
392     def handle_cwiczenie(self, element):
393         pre, post = super(Wybor, self).handle_cwiczenie(element)
394         is_single_choice = True
395         pytania = element.xpath(".//pytanie")
396         if not pytania:
397             pytania = [element]
398         for p in pytania:
399             solutions = re.split(r"[, ]+", p.attrib['rozw'])
400             if len(solutions) != 1:
401                 is_single_choice = False
402                 break
403             choices = p.xpath(".//*[@nazwa]")
404             uniq = set()
405             for n in choices: uniq.add(n.attrib['nazwa'])
406             if len(choices) != len(uniq):
407                 is_single_choice = False
408                 break
409
410         self.options = {'single': is_single_choice}
411         return pre, post
412
413     def handle_punkt(self, element):
414         if self.options['exercise'] and element.attrib.get('nazwa', None):
415             qc = self.question_counter
416             self.piece_counter += 1
417             no = self.piece_counter
418             eid = "q%(qc)d_%(no)d" % locals()
419             aname = element.attrib.get('nazwa', None)
420             if self.options['single']:
421                 return u"""
422 <li class="question-piece" data-qc="%(qc)d" data-no="%(no)d" data-name="%(aname)s">
423 <input type="radio" name="q%(qc)d" id="%(eid)s" value="%(aname)s" />
424 <label for="%(eid)s">
425                 """ % locals(), u"</label></li>"
426             else:
427                 return u"""
428 <li class="question-piece" data-qc="%(qc)d" data-no="%(no)d" data-name="%(aname)s">
429 <input type="checkbox" name="%(eid)s" id="%(eid)s" />
430 <label for="%(eid)s">
431 """ % locals(), u"</label></li>"
432
433         else:
434             return super(Wybor, self).handle_punkt(element)
435
436
437 class Uporzadkuj(Exercise):
438     INSTRUCTION = u"Kliknij wybraną odpowiedź i przeciągnij w nowe miejsce."
439
440     def handle_pytanie(self, element):
441         """
442 Overrides the returned content default handle_pytanie
443         """
444         # we ignore the result, returning our own
445         super(Uporzadkuj, self).handle_pytanie(element)
446         order_items = element.xpath(".//punkt/@rozw")
447
448         return u"""<div class="question" data-original="%s" data-no="%s">""" % \
449             (','.join(order_items), self.question_counter), \
450             u"""</div>"""
451
452     def handle_punkt(self, element):
453         return """<li class="question-piece" data-pos="%(rozw)s">""" \
454             % element.attrib,\
455             "</li>"
456
457
458 class Luki(Exercise):
459     INSTRUCTION = u"Przeciągnij odpowiedzi i upuść w wybranym polu."
460     def find_pieces(self, question):
461         return question.xpath(".//luka")
462
463     def solution_html(self, piece):
464         piece = deepcopy(piece)
465         piece.tail = None
466         sub = EduModule()
467         return sub.generate(piece)
468
469     def handle_pytanie(self, element):
470         qpre, qpost = super(Luki, self).handle_pytanie(element)
471
472         luki = list(enumerate(self.find_pieces(element)))
473         luki_html = ""
474         i = 0
475         random.shuffle(luki)
476         for (i, luka) in luki:
477             i += 1
478             luka_html = self.solution_html(luka)
479             luki_html += u'<span class="draggable question-piece" data-no="%d">%s</span>' % (i, luka_html)
480         self.words_html = '<div class="words">%s</div>' % luki_html
481
482         return qpre, qpost
483
484     def handle_opis(self, element):
485         return '', self.words_html
486
487     def handle_luka(self, element):
488         self.piece_counter += 1
489         return '<span class="placeholder" data-solution="%d"></span>' % self.piece_counter
490
491
492 class Zastap(Luki):
493     INSTRUCTION = u"Przeciągnij odpowiedzi i upuść je na słowie lub wyrażeniu, które chcesz zastąpić."
494
495     def find_pieces(self, question):
496         return question.xpath(".//zastap")
497
498     def solution_html(self, piece):
499         return piece.attrib['rozw']
500
501     def handle_zastap(self, element):
502         self.piece_counter += 1
503         return '<span class="placeholder zastap question-piece" data-solution="%d">' \
504             % self.piece_counter, '</span>'
505
506
507 class Przyporzadkuj(Exercise):
508     INSTRUCTION = [u"Przeciągnij odpowiedzi i upuść w wybranym polu.",
509                    u"Kliknij numer odpowiedzi, przeciągnij i upuść w wybranym polu."]
510
511     def get_instruction(self):
512         if not self.instruction_printed:
513             self.instruction_printed = True
514             return u'<span class="instruction">%s</span>' % self.INSTRUCTION[self.options['handles'] and 1 or 0]
515         else:
516             return ""
517
518     def handle_cwiczenie(self, element):
519         pre, post = super(Przyporzadkuj, self).handle_cwiczenie(element)
520         lista_with_handles = element.xpath(".//*[@uchwyty]")
521         if lista_with_handles:
522             self.options = {'handles': True}
523         return pre, post
524
525     def handle_pytanie(self, element):
526         pre, post = super(Przyporzadkuj, self).handle_pytanie(element)
527         minimum = element.attrib.get("min", None)
528         if minimum:
529             self.options = {"min": int(minimum)}
530         return pre, post
531
532     def handle_lista(self, lista):
533         if 'nazwa' in lista.attrib:
534             attrs = {
535                 'data-name': lista.attrib['nazwa'],
536                 'class': 'predicate'
537             }
538             self.options = {'predicate': True}
539         elif 'cel' in lista.attrib:
540             attrs = {
541                 'data-target': lista.attrib['cel'],
542                 'class': 'subject'
543             }
544             self.options = {'subject': True}
545         else:
546             attrs = {}
547         pre, post = super(Przyporzadkuj, self).handle_lista(lista, attrs)
548         return pre, post + '<br class="clr"/>'
549
550     def handle_punkt(self, element):
551         if self.options['subject']:
552             self.piece_counter += 1
553             if self.options['handles']:
554                 return '<li><span data-solution="%s" data-no="%s" class="question-piece draggable handle add-li">%s</span>' % (element.attrib['rozw'], self.piece_counter, self.piece_counter), '</li>'
555             else:
556                 return '<li data-solution="%s" data-no="%s" class="question-piece draggable">' % (element.attrib['rozw'], self.piece_counter), '</li>'
557
558         elif self.options['predicate']:
559             if self.options['min']:
560                 placeholders = u'<li class="placeholder"></li>' * self.options['min']
561             else:
562                 placeholders = u'<li class="placeholder multiple"></li>'
563             return '<li data-predicate="%(nazwa)s">' % element.attrib, '<ul class="subjects">' + placeholders + '</ul></li>'
564
565         else:
566             return super(Przyporzadkuj, self).handle_punkt(element)
567
568
569 class PrawdaFalsz(Exercise):
570     def handle_punkt(self, element):
571         if 'rozw' in element.attrib:
572             return u'''<li data-solution="%s" class="question-piece">
573             <span class="buttons">
574             <a href="#" data-value="true" class="true">Prawda</a>
575             <a href="#" data-value="false" class="false">Fałsz</a>
576         </span>''' % {'prawda': 'true', 'falsz': 'false'}[element.attrib['rozw']], '</li>'
577         else:
578             return super(PrawdaFalsz, self).handle_punkt(element)
579
580
581 class EduModuleFormat(Format):
582     PRIMARY_MATERIAL_FORMATS = ('pdf', 'odt')
583
584     class MaterialNotFound(BaseException):
585         pass
586
587     def __init__(self, wldoc, **kwargs):
588         super(EduModuleFormat, self).__init__(wldoc, **kwargs)
589
590     def build(self):
591         # Sort materials by slug.
592         self.materials_by_slug = {}
593         for name, att in self.wldoc.source.attachments.items():
594             parts = name.rsplit('.', 1)
595             if len(parts) == 1:
596                 continue
597             slug, ext = parts
598             if slug not in self.materials_by_slug:
599                 self.materials_by_slug[slug] = {}
600             self.materials_by_slug[slug][ext] = att
601
602         edumod = EduModule({'provider': self.wldoc.provider, 'urlmapper': self, 'wldoc': self.wldoc})
603
604         html = edumod.generate(self.wldoc.edoc.getroot())
605
606         return IOFile.from_string(html.encode('utf-8'))
607
608     def materials(self, slug):
609         """Returns a list of pairs: (ext, iofile)."""
610         order = dict(reversed(k) for k in enumerate(self.PRIMARY_MATERIAL_FORMATS))
611         mats = self.materials_by_slug.get(slug, {}).items()
612         if not mats:
613             print "!! Material missing: '%s'" % slug
614         return sorted(mats, key=lambda (x, y): order.get(x, x))
615
616     def url_for_material(self, slug, fmt):
617         return "%s.%s" % (slug, fmt)
618
619     def url_for_image(self, slug, fmt, width=None):
620         return self.url_for_material(self, slug, fmt)
621
622
623 def transform(wldoc, stylesheet='edumed', options=None, flags=None):
624     """Transforms the WL document to XHTML.
625
626     If output_filename is None, returns an XML,
627     otherwise returns True if file has been written,False if it hasn't.
628     File won't be written if it has no content.
629     """
630     edumodfor = EduModuleFormat(wldoc)
631     return edumodfor.build()