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