nowa -> 2017
[librarian.git] / librarian / pypdf.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 """PDF creation library.
7
8 Creates one big XML from the book and its children, converts it to LaTeX
9 with TeXML, then runs it by XeLaTeX.
10
11 """
12 from copy import deepcopy
13 import os.path
14 import shutil
15 import re
16 import random
17 from urllib2 import urlopen
18
19 from lxml import etree
20
21 from xmlutils import Xmill, ifoption, tag_open_close
22 from librarian import DCNS, get_resource, IOFile
23 from librarian import functions
24 from pdf import PDFFormat, substitute_hyphens, fix_hanging
25
26
27 def escape(really):
28     def deco(f):
29         def _wrap(*args, **kw):
30             value = f(*args, **kw)
31
32             prefix = (u'<TeXML escape="%d">' % (1 if really else 0))
33             postfix = u'</TeXML>'
34             if isinstance(value, list):
35                 import pdb
36                 pdb.set_trace()
37             if isinstance(value, tuple):
38                 return prefix + value[0], value[1] + postfix
39             else:
40                 return prefix + value + postfix
41         return _wrap
42     return deco
43
44
45 def cmd(name, parms=None):
46     def wrap(self, element=None):
47         pre, post = tag_open_close('cmd', name=name)
48
49         if parms:
50             for parm in parms:
51                 e = etree.Element("parm")
52                 e.text = parm
53                 pre += etree.tostring(e)
54         if element is not None:
55             pre += "<parm>"
56             post = "</parm>" + post
57             return pre, post
58         else:
59             return pre + post
60     return wrap
61
62
63 def mark_alien_characters(text):
64     text = re.sub(ur"([\u0400-\u04ff]+)", ur"<alien>\1</alien>", text)
65     return text
66
67
68 class EduModule(Xmill):
69     def __init__(self, options=None, state=None):
70         super(EduModule, self).__init__(options, state)
71         self.activity_counter = 0
72         self.activity_last = None
73         self.exercise_counter = 0
74
75         def swap_endlines(txt):
76             if self.options['strofa']:
77                 txt = txt.replace("/\n", '<ctrl ch="\\"/>')
78             return txt
79         self.register_text_filter(swap_endlines)
80         self.register_text_filter(functions.substitute_entities)
81         self.register_text_filter(mark_alien_characters)
82
83     def get_dc(self, element, dc_field, single=False):
84         values = map(lambda t: t.text, element.xpath("//dc:%s" % dc_field, namespaces={'dc': DCNS.uri}))
85         if single:
86             return values[0] if len(values) else ''
87         return values
88
89     def handle_rdf__RDF(self, _):
90         """skip metadata in generation"""
91         return
92
93     @escape(True)
94     def get_rightsinfo(self, element):
95         rights_lic = self.get_dc(element, 'rights.license', True)
96         return u'<cmd name="rightsinfostr">' + (u'<opt>%s</opt>' % rights_lic if rights_lic else '') + \
97             u'<parm>%s</parm>' % self.get_dc(element, 'rights', True) + \
98             u'</cmd>'
99
100     @escape(True)
101     def get_authors(self, element, which=None):
102         dc = self.options['wldoc'].book_info
103         if which is None:
104             authors = dc.authors_textbook + \
105                 dc.authors_scenario + \
106                 dc.authors_expert
107         else:
108             authors = getattr(dc, "authors_%s" % which)
109         return u', '.join(author.readable() for author in authors if author)
110
111     @escape(True)
112     def get_title(self, element):
113         return self.get_dc(element, 'title', True)
114
115     @escape(True)
116     def get_description(self, element):
117         desc = self.get_dc(element, 'description', single=True)
118         if not desc:
119             print '!! no description'
120         return desc
121
122     @escape(True)
123     def get_curriculum(self, element):
124         ret = []
125         for dc_tag, new in [('subject.curriculum', False), ('subject.curriculum.new', True)]:
126             identifiers = self.get_dc(element, dc_tag)
127             if not identifiers:
128                 continue
129             try:
130                 from curriculum.templatetags.curriculum_tags import curriculum
131                 curr_elements = curriculum(identifiers)
132             except ImportError:
133                 curr_elements = {'identifiers': identifiers}
134             items = ['Podstawa programowa 2017:' if new else 'Podstawa programowa:']
135             newline = '<ctrl ch="\\"/>\n'
136             if 'currset' in curr_elements:
137                 for (course, level), types in curr_elements['currset'].iteritems():
138                     label = u'klasa' if new else u'poziom edukacyjny'
139                     lines = [u'%s, %s %s' % (course, level, label)]
140                     for type, currs in types.iteritems():
141                         lines.append(type)
142                         lines += [curr.title for curr in currs]
143                     items.append(newline.join(lines))
144             else:
145                 items += identifiers
146             ret.append('\n<cmd name="vspace"><parm>.6em</parm></cmd>\n'.join(
147                 '<cmd name="akap"><parm>%s</parm></cmd>' % item for item in items))
148         return '\n<cmd name="vspace"><parm>1em</parm></cmd>\n'.join(ret)
149
150     def handle_utwor(self, element):
151         lines = [
152             u'''
153                 <TeXML xmlns="http://getfo.sourceforge.net/texml/ns1">
154                 <TeXML escape="0">
155                 \\documentclass[%s]{wl}
156                 \\usepackage{style}''' % self.options['customization_str'],
157             self.options['has_cover'] and '\usepackage{makecover}',
158             (self.options['morefloats'] == 'new' and '\usepackage[maxfloats=64]{morefloats}') or
159             (self.options['morefloats'] == 'old' and '\usepackage{morefloats}') or
160             (self.options['morefloats'] == 'none' and
161                 u'''\\IfFileExists{morefloats.sty}{
162                 \\usepackage{morefloats}
163                 }{}'''),
164             u'''\\def\\authors{%s}''' % self.get_authors(element),
165             u'''\\def\\authorsexpert{%s}''' % self.get_authors(element, 'expert'),
166             u'''\\def\\authorsscenario{%s}''' % self.get_authors(element, 'scenario'),
167             u'''\\def\\authorstextbook{%s}''' % self.get_authors(element, 'textbook'),
168             u'''\\def\\description{%s}''' % self.get_description(element),
169
170             u'''\\author{\\authors}''',
171             u'''\\title{%s}''' % self.get_title(element),
172             u'''\\def\\bookurl{%s}''' % self.options['wldoc'].book_info.url.canonical(),
173             u'''\\def\\rightsinfo{%s}''' % self.get_rightsinfo(element),
174             u'''\\def\\curriculum{%s}''' % self.get_curriculum(element),
175             u'</TeXML>'
176         ]
177
178         return u"".join(filter(None, lines)), u'</TeXML>'
179
180     @escape(True)
181     def handle_powiesc(self, element):
182         return u"""
183     <env name="document">
184     <cmd name="maketitle"/>
185     """, """<cmd name="editorialsection" /></env>"""
186
187     @escape(True)
188     def handle_texcommand(self, element):
189         cmd = functions.texcommand(element.tag)
190         return u'<TeXML escape="1"><cmd name="%s"><parm>' % cmd, u'</parm></cmd></TeXML>'
191
192     handle_akap = \
193         handle_akap_cd = \
194         handle_akap_dialog = \
195         handle_autor_utworu = \
196         handle_dedykacja = \
197         handle_didaskalia = \
198         handle_didask_tekst = \
199         handle_dlugi_cytat = \
200         handle_dzielo_nadrzedne = \
201         handle_lista_osoba = \
202         handle_mat = \
203         handle_miejsce_czas = \
204         handle_motto = \
205         handle_motto_podpis = \
206         handle_naglowek_akt = \
207         handle_naglowek_czesc = \
208         handle_naglowek_listy = \
209         handle_naglowek_osoba = \
210         handle_naglowek_scena = \
211         handle_nazwa_utworu = \
212         handle_nota = \
213         handle_osoba = \
214         handle_pa = \
215         handle_pe = \
216         handle_podtytul = \
217         handle_poezja_cyt = \
218         handle_pr = \
219         handle_pt = \
220         handle_sekcja_asterysk = \
221         handle_sekcja_swiatlo = \
222         handle_separator_linia = \
223         handle_slowo_obce = \
224         handle_srodtytul = \
225         handle_tytul_dziela = \
226         handle_wyroznienie = \
227         handle_dywiz = \
228         handle_texcommand
229
230     def handle_naglowek_rozdzial(self, element):
231         if not self.options['teacher']:
232             if element.text.startswith((u'Wiedza', u'Zadania', u'Słowniczek', u'Dla ucznia')):
233                 self.state['mute'] = False
234             else:
235                 self.state['mute'] = True
236                 return None
237         return self.handle_texcommand(element)
238     handle_naglowek_rozdzial.unmuter = True
239
240     def handle_naglowek_podrozdzial(self, element):
241         self.activity_counter = 0
242         if not self.options['teacher']:
243             if element.text.startswith(u'Dla ucznia'):
244                 self.state['mute'] = False
245                 return None
246             elif element.text.startswith(u'Dla nauczyciela'):
247                 self.state['mute'] = True
248                 return None
249             elif self.state['mute']:
250                 return None
251         return self.handle_texcommand(element)
252     handle_naglowek_podrozdzial.unmuter = True
253
254     def handle_uwaga(self, _e):
255         return None
256
257     def handle_extra(self, _e):
258         return None
259
260     def handle_nbsp(self, _e):
261         return '<spec cat="tilde" />'
262
263     _handle_strofa = cmd("strofa")
264
265     def handle_strofa(self, element):
266         self.options = {'strofa': True}
267         return self._handle_strofa(element)
268
269     def handle_aktywnosc(self, element):
270         self.activity_counter += 1
271         self.options = {
272             'activity': True,
273             'activity_counter': self.activity_counter,
274             'sub_gen': True,
275         }
276         submill = EduModule(self.options, self.state)
277
278         if element.xpath('opis'):
279             opis = submill.generate(element.xpath('opis')[0])
280         else:
281             opis = ''
282
283         n = element.xpath('wskazowki')
284         if n:
285             wskazowki = submill.generate(n[0])
286         else:
287             wskazowki = ''
288         n = element.xpath('pomoce')
289
290         if n:
291             pomoce = submill.generate(n[0])
292         else:
293             pomoce = ''
294
295         forma = ''.join(element.xpath('forma/text()'))
296
297         czas = ''.join(element.xpath('czas/text()'))
298
299         counter = self.activity_counter
300
301         if element.getnext().tag == 'aktywnosc' or (len(self.activity_last) and self.activity_last.getnext() == element):
302             counter_tex = """<cmd name="activitycounter"><parm>%(counter)d.</parm></cmd>""" % locals()
303         else:
304             counter_tex = ''
305
306         self.activity_last = element
307
308         return u"""
309 <cmd name="noindent" />
310 %(counter_tex)s
311 <cmd name="activityinfo"><parm>
312  <cmd name="activitytime"><parm>%(czas)s</parm></cmd>
313  <cmd name="activityform"><parm>%(forma)s</parm></cmd>
314  <cmd name="activitytools"><parm>%(pomoce)s</parm></cmd>
315 </parm></cmd>
316
317
318 %(opis)s
319
320 %(wskazowki)s
321 """ % locals()
322
323     handle_opis = ifoption(sub_gen=True)(lambda s, e: ('', ''))
324     handle_wskazowki = ifoption(sub_gen=True)(lambda s, e: ('', ''))
325
326     @ifoption(sub_gen=True)
327     def handle_pomoce(self, _):
328         return "Pomoce: ", ""
329
330     def handle_czas(self, *_):
331         return
332
333     def handle_forma(self, *_):
334         return
335
336     def handle_lista(self, element, attrs=None):
337         ltype = element.attrib.get('typ', 'punkt')
338         if not element.findall("punkt"):
339             if ltype == 'czytelnia':
340                 return 'W przygotowaniu.'
341             else:
342                 return None
343         if ltype == 'slowniczek':
344             surl = element.attrib.get('src', None)
345             if surl is None:
346                 # print '** missing src on <slowniczek>, setting default'
347                 surl = 'http://edukacjamedialna.edu.pl/lekcje/slowniczek/'
348             sxml = etree.fromstring(self.options['wldoc'].provider.by_uri(surl).get_string())
349             self.options = {'slowniczek': True, 'slowniczek_xml': sxml}
350
351         listcmd = {
352             'num': 'enumerate',
353             'punkt': 'itemize',
354             'alfa': 'itemize',
355             'slowniczek': 'itemize',
356             'czytelnia': 'itemize'
357         }[ltype]
358
359         return u'<env name="%s">' % listcmd, u'</env>'
360
361     def handle_punkt(self, element):
362         return '<cmd name="item"/>', ''
363
364     def handle_cwiczenie(self, element):
365         exercise_handlers = {
366             'wybor': Wybor,
367             'uporzadkuj': Uporzadkuj,
368             'luki': Luki,
369             'zastap': Zastap,
370             'przyporzadkuj': Przyporzadkuj,
371             'prawdafalsz': PrawdaFalsz
372         }
373
374         typ = element.attrib['typ']
375         self.exercise_counter += 1
376         if typ not in exercise_handlers:
377             return '(no handler)'
378         self.options = {'exercise_counter': self.exercise_counter}
379         handler = exercise_handlers[typ](self.options, self.state)
380         return handler.generate(element)
381
382     # XXX this is copied from pyhtml.py, except for return and
383     # should be refactored for no code duplication
384     def handle_definiendum(self, element):
385         nxt = element.getnext()
386         definiens_s = ''
387
388         # let's pull definiens from another document
389         if self.options['slowniczek_xml'] is not None and (nxt is None or nxt.tag != 'definiens'):
390             sxml = self.options['slowniczek_xml']
391             assert element.text != ''
392             if "'" in (element.text or ''):
393                 defloc = sxml.xpath("//definiendum[text()=\"%s\"]" % (element.text or '').strip())
394             else:
395                 defloc = sxml.xpath("//definiendum[text()='%s']" % (element.text or '').strip())
396             if defloc:
397                 definiens = defloc[0].getnext()
398                 if definiens.tag == 'definiens':
399                     subgen = EduModule(self.options, self.state)
400                     definiens_s = subgen.generate(definiens)
401
402         return u'<cmd name="textbf"><parm>', u"</parm></cmd>: " + definiens_s
403
404     def handle_definiens(self, element):
405         return u"", u""
406
407     def handle_podpis(self, element):
408         return u"""<env name="figure">""", u"</env>"
409
410     def handle_tabela(self, element):
411         max_col = 0
412         for w in element.xpath("wiersz"):
413             ks = w.xpath("kol")
414             if max_col < len(ks):
415                 max_col = len(ks)
416         self.options = {'columnts': max_col}
417         # styling:
418         #     has_frames = int(element.attrib.get("ramki", "0"))
419         #     if has_frames: frames_c = "framed"
420         #     else: frames_c = ""
421         #     return u"""<table class="%s">""" % frames_c, u"</table>"
422         return u'''
423 <cmd name="begin"><parm>tabular</parm><parm>%s</parm></cmd>
424     ''' % ('l' * max_col), u'''<cmd name="end"><parm>tabular</parm></cmd>'''
425
426     @escape(True)
427     def handle_wiersz(self, element):
428         return u"", u'<ctrl ch="\\"/>'
429
430     @escape(True)
431     def handle_kol(self, element):
432         if element.getnext() is not None:
433             return u"", u'<spec cat="align" />'
434         return u"", u""
435
436     def handle_link(self, element):
437         if element.attrib.get('url'):
438             url = element.attrib.get('url')
439             if url == element.text:
440                 return cmd('url')(self, element)
441             else:
442                 return cmd('href', parms=[element.attrib['url']])(self, element)
443         else:
444             return cmd('emph')(self, element)
445
446     def handle_obraz(self, element):
447         frmt = self.options['format']
448         name = element.attrib.get('nazwa', '').strip()
449         image = frmt.get_image(name.strip())
450         name = image.get_filename().rsplit('/', 1)[-1]
451         img_path = "obraz/%s" % name.replace("_", "")
452         frmt.attachments[img_path] = image
453         return cmd("obraz", parms=[img_path])(self)
454
455     def handle_video(self, element):
456         url = element.attrib.get('url')
457         if not url:
458             print '!! <video> missing url'
459             return
460         m = re.match(r'(?:https?://)?(?:www.)?youtube.com/watch\?(?:.*&)?v=([^&]+)(?:$|&)', url)
461         if not m:
462             print '!! unknown <video> url scheme:', url
463             return
464         name = m.group(1)
465         thumb = IOFile.from_string(urlopen("http://img.youtube.com/vi/%s/0.jpg" % name).read())
466         img_path = "video/%s.jpg" % name.replace("_", "")
467         self.options['format'].attachments[img_path] = thumb
468         canon_url = "https://www.youtube.com/watch?v=%s" % name
469         return cmd("video", parms=[img_path, canon_url])(self)
470
471
472 class Exercise(EduModule):
473     def __init__(self, *args, **kw):
474         self.question_counter = 0
475         super(Exercise, self).__init__(*args, **kw)
476         self.piece_counter = None
477
478     handle_rozw_kom = ifoption(teacher=True)(cmd('akap'))
479
480     def handle_cwiczenie(self, element):
481         self.options = {
482             'exercise': element.attrib['typ'],
483             'sub_gen': True,
484         }
485         self.question_counter = 0
486         self.piece_counter = 0
487
488         header = etree.Element("parm")
489         header_cmd = etree.Element("cmd", name="naglowekpodrozdzial")
490         header_cmd.append(header)
491         header.text = u"Zadanie %d." % self.options['exercise_counter']
492
493         pre = etree.tostring(header_cmd, encoding=unicode)
494         post = u""
495         # Add a single <pytanie> tag if it's not there
496         if not element.xpath(".//pytanie"):
497             qpre, qpost = self.handle_pytanie(element)
498             pre += qpre
499             post = qpost + post
500         return pre, post
501
502     def handle_pytanie(self, element):
503         """This will handle <cwiczenie> element, when there is no <pytanie>
504         """
505         self.question_counter += 1
506         self.piece_counter = 0
507         pre = post = u""
508         if self.options['teacher'] and element.attrib.get('rozw'):
509             post += u" [rozwiązanie: %s]" % element.attrib.get('rozw')
510         return pre, post
511
512     def handle_punkt(self, element):
513         pre, post = super(Exercise, self).handle_punkt(element)
514         if self.options['teacher'] and element.attrib.get('rozw'):
515             post += u" [rozwiązanie: %s]" % element.attrib.get('rozw')
516         return pre, post
517
518     def solution_header(self):
519         par = etree.Element("cmd", name="par")
520         parm = etree.Element("parm")
521         parm.text = u"Rozwiązanie:"
522         par.append(parm)
523         return etree.tostring(par)
524
525     def explicit_solution(self):
526         if self.options['solution']:
527             par = etree.Element("cmd", name="par")
528             parm = etree.Element("parm")
529             parm.text = self.options['solution']
530             par.append(parm)
531             return self.solution_header() + etree.tostring(par)
532
533
534 class Wybor(Exercise):
535     def handle_cwiczenie(self, element):
536         pre, post = super(Wybor, self).handle_cwiczenie(element)
537         is_single_choice = True
538         pytania = element.xpath(".//pytanie")
539         if not pytania:
540             pytania = [element]
541         for p in pytania:
542             solutions = p.xpath(".//punkt[@rozw='prawda']")
543             if len(solutions) != 1:
544                 is_single_choice = False
545                 break
546
547         self.options = {'single': is_single_choice}
548         return pre, post
549
550     def handle_punkt(self, element):
551         if self.options['exercise'] and element.attrib.get('rozw', None):
552             cmd = 'radio' if self.options['single'] else 'checkbox'
553             if self.options['teacher'] and element.attrib['rozw'] == 'prawda':
554                 cmd += 'checked'
555             return u'<cmd name="%s"/>' % cmd, ''
556         else:
557             return super(Wybor, self).handle_punkt(element)
558
559
560 class Uporzadkuj(Exercise):
561     def handle_pytanie(self, element):
562         order_items = element.xpath(".//punkt/@rozw")
563         return super(Uporzadkuj, self).handle_pytanie(element)
564
565
566 class Przyporzadkuj(Exercise):
567     def handle_lista(self, lista):
568         header = etree.Element("parm")
569         header_cmd = etree.Element("cmd", name="par")
570         header_cmd.append(header)
571         if 'nazwa' in lista.attrib:
572             header.text = u"Kategorie:"
573         elif 'cel' in lista.attrib:
574             header.text = u"Elementy do przyporządkowania:"
575         else:
576             header.text = u"Lista:"
577         pre, post = super(Przyporzadkuj, self).handle_lista(lista)
578         pre = etree.tostring(header_cmd, encoding=unicode) + pre
579         return pre, post
580
581
582 class Luki(Exercise):
583     def find_pieces(self, question):
584         return question.xpath(".//luka")
585
586     def solution(self, piece):
587         piece = deepcopy(piece)
588         piece.tail = None
589         sub = EduModule()
590         return sub.generate(piece)
591
592     def handle_pytanie(self, element):
593         qpre, qpost = super(Luki, self).handle_pytanie(element)
594
595         luki = self.find_pieces(element)
596         random.shuffle(luki)
597         self.words = u"<env name='itemize'>%s</env>" % (
598             "".join("<cmd name='item'/>%s" % self.solution(luka) for luka in luki)
599         )
600         return qpre, qpost
601
602     def handle_opis(self, element):
603         return '', self.words
604
605     def handle_luka(self, element):
606         luka = "_" * 10
607         if self.options['teacher']:
608             piece = deepcopy(element)
609             piece.tail = None
610             sub = EduModule()
611             text = sub.generate(piece)
612             luka += u" [rozwiązanie: %s]" % text
613         return luka
614
615
616 class Zastap(Luki):
617     def find_pieces(self, question):
618         return question.xpath(".//zastap")
619
620     def solution(self, piece):
621         return piece.attrib.get('rozw', '')
622
623     def list_header(self):
624         return u"Elementy do wstawienia"
625
626     def handle_zastap(self, element):
627         piece = deepcopy(element)
628         piece.tail = None
629         sub = EduModule()
630         text = sub.generate(piece)
631         if self.options['teacher'] and element.attrib.get('rozw'):
632             text += u" [rozwiązanie: %s]" % element.attrib.get('rozw')
633         return text
634
635
636 class PrawdaFalsz(Exercise):
637     def handle_punkt(self, element):
638         pre, post = super(PrawdaFalsz, self).handle_punkt(element)
639         if 'rozw' in element.attrib:
640             post += u" [Prawda/Fałsz]"
641         return pre, post
642
643
644 def fix_lists(tree):
645     lists = tree.xpath(".//lista")
646     for l in lists:
647         if l.text:
648             p = l.getprevious()
649             if p is not None:
650                 if p.tail is None:
651                     p.tail = ''
652                 p.tail += l.text
653             else:
654                 p = l.getparent()
655                 if p.text is None:
656                     p.text = ''
657                 p.text += l.text
658             l.text = ''
659     return tree
660
661
662 class EduModulePDFFormat(PDFFormat):
663     style = get_resource('res/styles/edumed/pdf/edumed.sty')
664
665     def get_texml(self):
666         substitute_hyphens(self.wldoc.edoc)
667         fix_hanging(self.wldoc.edoc)
668
669         self.attachments = {}
670         edumod = EduModule({
671             "wldoc": self.wldoc,
672             "format": self,
673             "teacher": self.customization.get('teacher'),
674         })
675         texml = edumod.generate(fix_lists(self.wldoc.edoc.getroot())).encode('utf-8')
676
677         open("/tmp/texml.xml", "w").write(texml)
678         return texml
679
680     def get_tex_dir(self):
681         temp = super(EduModulePDFFormat, self).get_tex_dir()
682         shutil.copy(get_resource('res/styles/edumed/logo.png'), temp)
683         for name, iofile in self.attachments.items():
684             iofile.save_as(os.path.join(temp, name))
685         return temp
686
687     def get_image(self, name):
688         return self.wldoc.source.attachments[name]