release
[librarian.git] / src / librarian / builders / epub.py
1 # This file is part of Librarian, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
3 #
4 from datetime import date
5 import io
6 import os
7 import re
8 import tempfile
9 from ebooklib import epub
10 from lxml import etree
11 from librarian import functions, OutputFile, get_resource, XHTMLNS
12 from librarian.cover import make_cover
13 from librarian.embeds.mathml import MathML
14 from librarian.fonts import strip_font
15
16
17 class Xhtml:
18     def __init__(self):
19         self.element = etree.XML('''<html xmlns="http://www.w3.org/1999/xhtml"><head><link rel="stylesheet" href="style.css" type="text/css"/><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/><title>WolneLektury.pl</title></head><body/></html>''')
20
21     @property
22     def title(self):
23         return self.element.find('.//' + XHTMLNS('title'))
24         
25     @property
26     def body(self):
27         return self.element.find('.//' + XHTMLNS('body'))
28
29
30 class Builder:
31     file_extension = None
32
33     def __init__(self, base_url=None, fundraising=None, cover=None):
34         self._base_url = base_url or 'file:///home/rczajka/for/fnp/librarian/temp~/maly/img/'
35         self.fundraising = fundraising
36         self.footnotes = etree.Element('div', id='footnotes')
37         self.make_cover = cover or make_cover
38
39         self.cursors = {
40 #            None: None,
41 #            'header': self.header,
42             'footnotes': self.footnotes,
43         }
44         self.current_cursors = []
45
46         self.toc_base = 0
47
48     @property
49     def cursor(self):
50         return self.current_cursors[-1]
51
52     def enter_fragment(self, fragment):
53         self.current_cursors.append(self.cursors[fragment])
54
55     def exit_fragment(self):
56         self.current_cursors.pop()
57
58     def create_fragment(self, name, element):
59         assert name not in self.cursors
60         self.cursors[name] = element
61
62     def forget_fragment(self, name):
63         del self.cursors[name]
64
65     @property
66     def base_url(self):
67         if self._base_url is not None:
68             return self._base_url
69         else:
70             return 'https://wolnelektury.pl/media/book/pictures/{}/'.format(self.document.meta.url.slug)
71
72
73     # Base URL should be on Document level, not builder.
74     def build(self, document, **kwargs):
75         """Should return an OutputFile with the output."""
76         raise NotImplementedError()
77
78
79 class EpubBuilder(Builder):
80     file_extension = 'epub'
81     isbn_field = 'isbn_epub'
82     orphans = True
83
84     def __init__(self, *args, debug=False, **kwargs):
85         self.chars = set()
86         self.fundr = 0
87         self.debug = debug
88         self.splits = []
89         super().__init__(*args, **kwargs)
90     
91     def build(self, document, **kwargs):
92         # replace_characters -- nie, robimy to na poziomie elementów
93         
94         # hyphenator (\00ad w odp. miejscach) -- jeśli już, to też powinno to się dziać na poziomie elementów
95         # spójniki (\u00a0 po)-- jeśli już, to na poziomie elementów
96         # trick na dywizy: &#xad;&#8288;-
97
98         # do toc trafia:
99         #   początek z KAŻDEGO PLIKU xml
100         
101         # zliczamy zbiór użytych znaków
102
103         # flagi:
104         # mieliśmy taką flagę less-advertising, używaną tylko dla Prestigio; już nie używamy.
105
106         # @editors = document.editors() (jako str)
107         # @funders = join(meta.funders)
108         # @thanks = meta.thanks
109
110
111         self.output = output = epub.EpubBook()
112         self.document = document
113
114         self.set_metadata()
115         
116         self.add_cover()
117         
118         self.add_title_page()
119         self.add_toc()
120
121
122
123         self.start_chunk()
124
125         self.add_toc_entry(
126             None,
127             'Początek utworu', # i18n
128             0
129         )
130         self.output.guide.append({
131             "type": "text",
132             "title": "Początek",
133             "href": "part1.xhtml"
134         })
135
136
137         self.build_document(self.document)
138
139         
140         self.close_chunk()
141
142         self.add_annotations()
143         self.add_support_page()
144         self.add_last_page()
145
146         if self.fundraising:
147             e = len(self.output.spine) - 3 - 3
148             nfunds = len(self.fundraising)
149             if e > 4 * nfunds:
150                 nfunds *= 2
151
152             # COUNTING CHARACTERS?
153             for f in range(nfunds):
154                 spine_index = int(4 + (f / nfunds * e) + f)
155
156                 h = Xhtml()
157                 h.body.append(
158                     etree.XML('<div id="book-text"><div class="fundraising">' + self.fundraising[f % len(self.fundraising)] + '</div></div>')
159                 )
160                 self.add_html(h.element, file_name='fund%d.xhtml' % f, spine=spine_index)
161
162         self.add_fonts()
163
164         output_file = tempfile.NamedTemporaryFile(
165             prefix='librarian', suffix='.epub',
166             delete=False)
167         output_file.close()
168         epub.write_epub(output_file.name, output, {'epub3_landmark': False})
169         return OutputFile.from_filename(output_file.name)
170
171     def build_document(self, document):
172         self.toc_precedences = []
173
174         self.start_chunk()
175
176
177         document.tree.getroot().epub_build(self)
178         if document.meta.parts:
179             self.start_chunk()
180
181             self.start_element('div', {'class': 'title-page'})
182             self.start_element('h1', {'class': 'title'})
183             self.push_text(document.meta.title)
184             self.end_element()
185             self.end_element()
186
187             ######
188             # 160
189             # translators
190             # working copy?
191             # ta lektura
192             # tanks
193             # utwor opracowany
194             # isbn
195             # logo
196
197             for child in document.children:
198                 self.start_chunk()
199                 self.add_toc_entry(None, child.meta.title, 0)
200                 self.build_document(child)
201
202         self.shift_toc_base()
203             
204     
205     def add_title_page(self):
206         html = Xhtml()
207         html.title.text = "Strona tytułowa"
208         bt = etree.SubElement(html.body, 'div', **{'id': 'book-text'})
209         tp = etree.SubElement(bt, 'div', **{'class': 'title-page'})
210
211         # Tak jak jest teraz – czy może być jednocześnie
212         # no „autor_utworu”
213         # i „dzieło nadrzędne”
214         # wcześniej mogło być dzieło nadrzędne,
215
216         e = self.document.tree.find('//autor_utworu')
217         if e is not None:
218             etree.SubElement(tp, 'h2', **{'class': 'author'}).text = e.raw_printable_text(self)
219         e = self.document.tree.find('//nazwa_utworu')
220         if e is not None:
221             etree.SubElement(tp, 'h1', **{'class': 'title'}).text = e.raw_printable_text(self)
222
223         if not len(tp):
224             for author in self.document.meta.authors:
225                 etree.SubElement(tp, 'h2', **{'class': 'author'}).text = author.readable()
226             etree.SubElement(tp, 'h1', **{'class': 'title'}).text = self.document.meta.title
227
228 #                <xsl:apply-templates select="//nazwa_utworu | //podtytul | //dzielo_nadrzedne" mode="poczatek"/>
229 #        else:
230 #                            <xsl:apply-templates select="//dc:creator" mode="poczatek"/>
231 #                <xsl:apply-templates select="//dc:title | //podtytul | //dzielo_nadrzedne" mode="poczatek"/>
232
233         etree.SubElement(tp, 'p', **{"class": "info"}).text = '\u00a0'
234
235         if self.document.meta.translators:
236             p = etree.SubElement(tp, 'p', **{'class': 'info'})
237             p.text = 'tłum. ' + ', '.join(t.readable() for t in self.document.meta.translators)
238                 
239         #<p class="info">[Kopia robocza]</p>
240
241         p = etree.XML("""<p class="info">
242               <a>Ta lektura</a>, podobnie jak tysiące innych, jest dostępna on-line na stronie
243               <a href="https://wolnelektury.pl/">wolnelektury.pl</a>.
244             </p>""")
245         p[0].attrib['href'] = str(self.document.meta.url)
246         tp.append(p)
247
248         if self.document.meta.thanks:
249             etree.SubElement(tp, 'p', **{'class': 'info'}).text = self.document.meta.thanks
250         
251         tp.append(etree.XML("""
252           <p class="info">
253             Utwór opracowany został w&#160;ramach projektu<a href="https://wolnelektury.pl/"> Wolne Lektury</a> przez<a href="https://fundacja.wolnelektury.pl/"> fundację Wolne Lektury</a>.
254           </p>
255         """))
256
257         if getattr(self.document.meta, self.isbn_field):
258             etree.SubElement(tp, 'p', **{"class": "info"}).text = getattr(self.document.meta, self.isbn_field)
259
260         tp.append(etree.XML("""<p class="footer info">
261             <a href="https://wolnelektury.pl/"><img src="logo_wolnelektury.png" alt="WolneLektury.pl" /></a>
262         </p>"""))
263
264         self.add_html(
265             html.element,
266             file_name='title.xhtml',
267             spine=True,
268             toc='Strona tytułowa' # TODO: i18n
269         )
270
271         self.add_file(
272             get_resource('res/wl-logo-small.png'),
273             file_name='logo_wolnelektury.png',
274             media_type='image/png'
275         )
276     
277     def set_metadata(self):
278         self.output.set_identifier(
279             str(self.document.meta.url))
280         self.output.set_language(
281             functions.lang_code_3to2(self.document.meta.language)
282         )
283         self.output.set_title(self.document.meta.title)
284
285         for i, author in enumerate(self.document.meta.authors):
286             self.output.add_author(
287                 author.readable(),
288                 file_as=str(author),
289                 uid='creator{}'.format(i)
290             )
291         for translator in self.document.meta.translators:
292             self.output.add_author(
293                 translator.readable(),
294                 file_as=str(translator),
295                 role='trl',
296                 uid='translator{}'.format(i)
297             )
298         for publisher in self.document.meta.publisher:
299             self.output.add_metadata("DC", "publisher", publisher)
300
301         self.output.add_metadata("DC", "date", self.document.meta.created_at)
302
303         
304
305
306     def add_toc(self):
307         item = epub.EpubNav()
308         item.add_link(href='style.css', rel='stylesheet', type='text/css')
309         self.output.add_item(item)
310         self.output.spine.append(item)
311         self.output.add_item(epub.EpubNcx())
312
313         self.output.toc.append(
314             epub.Link(
315                 "nav.xhtml",
316                 "Spis treści",
317                 "nav"
318             )
319         )
320
321     
322
323     def add_support_page(self):
324         self.add_file(
325             get_resource('res/epub/support.xhtml'),
326             spine=True,
327             toc='Wesprzyj Wolne Lektury'
328         )
329
330         self.add_file(
331             get_resource('res/jedenprocent.png'),
332             media_type='image/png'
333         )
334         self.add_file(
335             get_resource('res/epub/style.css'),
336             media_type='text/css'
337         )
338
339
340     def add_file(self, path=None, content=None,
341                  media_type='application/xhtml+xml',
342                  file_name=None, uid=None,
343                  spine=False, toc=None):
344
345         # update chars?
346         # jakieś tam ścieśnianie białych znaków?
347
348         if content is None:
349             with open(path, 'rb') as f:
350                 content = f.read()
351             if file_name is None:
352                 file_name = path.rsplit('/', 1)[-1]
353
354         if uid is None:
355             uid = file_name.split('.', 1)[0]
356
357         item = epub.EpubItem(
358             uid=uid,
359             file_name=file_name,
360             media_type=media_type,
361             content=content
362         )
363
364         self.output.add_item(item)
365         if spine:
366             if spine is True:
367                 self.output.spine.append(item)
368             else:
369                 self.output.spine.insert(spine, item)
370
371         if toc:
372             self.output.toc.append(
373                 epub.Link(
374                     file_name,
375                     toc,
376                     uid
377                 )
378             )
379
380     def add_html(self, html_tree, **kwargs):
381         html = etree.tostring(
382             html_tree, pretty_print=True, xml_declaration=True,
383             encoding="utf-8",
384             doctype='<!DOCTYPE html>'
385         )
386
387         self.add_file(
388             content=html,
389             **kwargs
390         )
391             
392         
393     def add_fonts(self):
394         for fname in ('DejaVuSerif.ttf', 'DejaVuSerif-Bold.ttf',
395                       'DejaVuSerif-Italic.ttf', 'DejaVuSerif-BoldItalic.ttf'):
396             self.add_file(
397                 content=strip_font(
398                     get_resource('fonts/' + fname),
399                     self.chars
400                 ),
401                 file_name=fname,
402                 media_type='font/ttf'
403             )
404
405     def start_chunk(self):
406         if getattr(self, 'current_chunk', None) is not None:
407             if not len(self.current_chunk):
408                 return
409             self.close_chunk()
410         self.current_chunk = etree.Element(
411             'div',
412             id="book-text"
413         )
414         self.cursors[None] = self.current_chunk
415         self.current_cursors.append(self.current_chunk)
416
417         self.section_number = 0
418         
419
420     def close_chunk(self):
421         assert self.cursor is self.current_chunk
422         ###### -- what if we're inside?
423
424         chunk_no = getattr(
425             self,
426             'chunk_counter',
427             1
428         )
429         self.chunk_counter = chunk_no + 1
430
431         html = Xhtml()
432         html.body.append(self.current_chunk)
433         
434         self.add_html(
435             ## html container from template.
436             #self.current_chunk,
437             html.element,
438             file_name='part%d.xhtml' % chunk_no,
439             spine=True,
440             
441         )
442         self.current_chunk = None
443         self.current_cursors.pop()
444
445     def start_element(self, tag, attr):
446         self.current_cursors.append(
447             etree.SubElement(self.cursor, tag, **attr)
448         )
449         
450     def end_element(self):
451         self.current_cursors.pop()
452         
453     def push_text(self, text):
454         self.chars.update(text)
455         if len(self.cursor):
456             self.cursor[-1].tail = (self.cursor[-1].tail or '') + text
457         else:
458             self.cursor.text = (self.cursor.text or '') + text
459
460
461     def assign_image_number(self):
462         image_number = getattr(self, 'image_number', 0)
463         self.image_number = image_number + 1
464         return image_number
465
466     def assign_footnote_number(self):
467         number = getattr(self, 'footnote_number', 1)
468         self.footnote_number = number + 1
469         return number
470
471     def assign_section_number(self):
472         number = getattr(self, 'section_number', 1)
473         self.section_number = number + 1
474         return number
475
476     def assign_mathml_number(self):
477         number = getattr(self, 'mathml_number', 0)
478         self.mathml_number = number + 1
479         return number
480
481     
482     def add_toc_entry(self, fragment, name, precedence):
483         if precedence:
484             while self.toc_precedences and self.toc_precedences[-1] >= precedence:
485                 self.toc_precedences.pop()
486         else:
487             self.toc_precedences = []
488
489         real_level = self.toc_base + len(self.toc_precedences)
490         if precedence:
491             self.toc_precedences.append(precedence)
492         else:
493             self.toc_base += 1
494         
495         part_number = getattr(
496             self,
497             'chunk_counter',
498             1
499         )
500         filename = 'part%d.xhtml' % part_number
501         uid = filename.split('.')[0]
502         if fragment:
503             filename += '#' + fragment
504             uid += '-' + fragment
505
506         toc = self.output.toc
507         for l in range(1, real_level):
508             if isinstance(toc[-1], epub.Link):
509                 toc[-1] = [toc[-1], []]
510             toc = toc[-1][1]
511
512         toc.append(
513             epub.Link(
514                 filename,
515                 name,
516                 uid
517             )
518         )
519
520     def shift_toc_base(self):
521         self.toc_base -= 1
522         
523
524     def add_last_page(self):
525         html = Xhtml()
526         m = self.document.meta
527         
528         html.title.text = 'Strona redakcyjna'
529         d = etree.SubElement(html.body, 'div', id='book-text')
530
531         newp = lambda: etree.SubElement(d, 'p', {'class': 'info'})
532
533         p = newp()
534         p.text = (
535             "Wszystkie zasoby Wolnych Lektur możesz swobodnie wykorzystywać, "
536             "publikować i rozpowszechniać pod warunkiem zachowania warunków "
537             "licencji i zgodnie z "
538         )
539         a = etree.SubElement(p, "a", href="https://wolnelektury.pl/info/zasady-wykorzystania/")
540         a.text = "Zasadami wykorzystania Wolnych Lektur"
541         a.tail = "."
542
543         etree.SubElement(p, "br")
544         
545
546         if m.license:
547             p[-1].tail = "Ten utwór jest udostępniony na licencji "
548             etree.SubElement(p, 'a', href=m.license).text = m.license_description
549         else:
550             p[-1].tail = 'Ten utwór jest w domenie publicznej.'
551
552         etree.SubElement(p, "br")
553         
554         p[-1].tail = (
555             "Wszystkie materiały dodatkowe (przypisy, motywy literackie) są "
556             "udostępnione na "
557             )
558         etree.SubElement(p, 'a', href='https://artlibre.org/licence/lal/pl/').text = 'Licencji Wolnej Sztuki 1.3'
559         p[-1].tail = '.'
560         etree.SubElement(p, "br")
561         p[-1].tail = (
562             "Fundacja Wolne Lektury zastrzega sobie prawa do wydania "
563             "krytycznego zgodnie z art. Art.99(2) Ustawy o prawach autorskich "
564             "i prawach pokrewnych. Wykorzystując zasoby z Wolnych Lektur, "
565             "należy pamiętać o zapisach licencji oraz zasadach, które "
566             "spisaliśmy w "
567         )
568
569         etree.SubElement(p, 'a', href='https://wolnelektury.pl/info/zasady-wykorzystania/').text = 'Zasadach wykorzystania Wolnych Lektur'
570         p[-1].tail = '. Zapoznaj się z nimi, zanim udostępnisz dalej nasze książki.'
571
572         p = newp()
573         p.text = 'E-book można pobrać ze strony: '
574         etree.SubElement(
575             p, 'a', href=str(m.url),
576             title=', '.join((
577                 ', '.join(p.readable() for p in m.authors),
578                 m.title
579             ))
580         ).text = str(m.url)
581
582         if m.source_name:
583             newp().text = 'Tekst opracowany na podstawie: ' + m.source_name
584
585         newp().text = """
586               Wydawca:
587               """ + ", ".join(p for p in m.publisher)
588
589         if m.description:
590             newp().text = m.description
591
592
593         editors = self.document.editors()
594         if editors:
595             newp().text = 'Opracowanie redakcyjne i przypisy: %s.' % (
596                 ', '.join(e.readable() for e in sorted(editors))
597             )
598
599         if m.funders:
600             etree.SubElement(d, 'p', {'class': 'minor-info'}).text = '''Publikację wsparli i wsparły:
601             %s.''' % (', '.join(m.funders))
602
603         if m.cover_by:
604             p = newp()
605             p.text = 'Okładka na podstawie: '
606             if m.cover_source:
607                 etree.SubElement(
608                     p,
609                     'a',
610                     href=m.cover_source
611                 ).text = m.cover_by
612             else:
613                 p.text += m.cover_by
614             
615         if getattr(m, self.isbn_field):
616             newp().text = getattr(m, self.isbn_field)
617
618         newp().text = '\u00a0'
619
620         p = newp()
621         p.attrib['class'] = 'minor-info'
622         p.text = '''
623               Plik wygenerowany dnia '''
624         span = etree.SubElement(p, 'span', id='file_date')
625         span.text = str(date.today())
626         span.tail = '''.
627           '''
628         
629         self.add_html(
630             html.element,
631             file_name='last.xhtml',
632             toc='Strona redakcyjna',
633             spine=True
634         )
635
636
637     def add_annotations(self):
638         if not len(self.footnotes):
639             return
640
641         html = Xhtml()
642         html.title.text = 'Przypisy'
643         d = etree.SubElement(
644             etree.SubElement(
645                 html.body,
646                 'div',
647                 id='book-text'
648             ),
649             'div',
650             id='footnotes'
651         )
652         
653         etree.SubElement(
654             d,
655             'h2',
656         ).text = 'Przypisy:'
657
658         d.extend(self.footnotes)
659         
660         self.add_html(
661             html.element,
662             file_name='annotations.xhtml',
663             spine=True,
664             toc='Przypisy'
665         )
666
667     def add_cover(self):
668         # TODO: allow other covers
669
670         cover_maker = self.make_cover
671
672         cover_file = io.BytesIO()
673         cover = cover_maker(self.document.meta, width=600)
674         cover.save(cover_file)
675         cover_name = 'cover.%s' % cover.ext()
676
677         self.output.set_cover(
678             file_name=cover_name,
679             content=cover_file.getvalue(),
680             create_page = False
681         )
682         ci = ('''<?xml version="1.0" encoding="UTF-8"?>
683 <!DOCTYPE html>
684 <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
685  <head>
686   <title>Okładka</title>
687   <style>
688     body { margin: 0em; padding: 0em; }
689     img { width: 100%%; }
690   </style>
691  </head>
692  <body>
693    <img src="cover.%s" alt="Okładka" />
694  </body>
695 </html>''' % cover.ext()).encode('utf-8')
696         self.add_file(file_name='cover.xhtml', content=ci)
697
698         self.output.spine.append(('cover', 'no'))
699         self.output.guide.append({
700             'type': 'cover',
701             'href': 'cover.xhtml',
702             'title': 'Okładka'
703         })
704
705     def mathml(self, element):
706         name = "math%d.png" % self.assign_mathml_number()
707         self.add_file(
708             content=MathML(element).to_latex().to_png().data,
709             media_type='image/png',
710             file_name=name
711         )
712         return name
713
714     def process_comment(self, comment):
715         m = re.match(r'TRIM:(\d+)', comment.text)
716         if m is not None:
717             self.splits.append(comment.sourceline - int(m.group(1)))