Images: image@src is a URL, and image sizes are limited.
[librarian.git] / src / librarian / pdf.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 __future__ import print_function, unicode_literals
13
14 import os
15 import os.path
16 import shutil
17 from tempfile import mkdtemp, NamedTemporaryFile
18 import re
19 from copy import deepcopy
20 from subprocess import call, PIPE
21 from itertools import chain
22
23 from PIL import Image
24 from Texml.processor import process
25 from lxml import etree
26 from lxml.etree import XMLSyntaxError, XSLTApplyError
27 import six
28
29 from librarian.dcparser import Person
30 from librarian.parser import WLDocument
31 from librarian import ParseError, DCNS, get_resource, OutputFile, RDFNS
32 from librarian import functions
33 from librarian.cover import make_cover
34 from .sponsor import sponsor_logo
35
36
37 functions.reg_substitute_entities()
38 functions.reg_strip()
39 functions.reg_starts_white()
40 functions.reg_ends_white()
41 functions.reg_texcommand()
42
43 STYLESHEETS = {
44     'wl2tex': 'pdf/wl2tex.xslt',
45 }
46
47 # CUSTOMIZATIONS = [
48 #     'nofootnotes',
49 #     'nothemes',
50 #     'defaultleading',
51 #     'onehalfleading',
52 #     'doubleleading',
53 #     'nowlfont',
54 # ]
55
56
57 def insert_tags(doc, split_re, tagname, exclude=None):
58     """
59     Inserts <tagname> for every occurence of `split_re'
60     in text nodes in the `doc' tree.
61
62     >>> t = etree.fromstring('<a><b>A-B-C</b>X-Y-Z</a>')
63     >>> insert_tags(t, re.compile('-'), 'd')
64     >>> print(etree.tostring(t, encoding='unicode'))
65     <a><b>A<d/>B<d/>C</b>X<d/>Y<d/>Z</a>
66     """
67
68     for elem in doc.iter(tag=etree.Element):
69         if exclude and elem.tag in exclude:
70             continue
71         if elem.text:
72             chunks = split_re.split(elem.text)
73             while len(chunks) > 1:
74                 ins = etree.Element(tagname)
75                 ins.tail = chunks.pop()
76                 elem.insert(0, ins)
77             elem.text = chunks.pop(0)
78         if elem.tail:
79             chunks = split_re.split(elem.tail)
80             parent = elem.getparent()
81             ins_index = parent.index(elem) + 1
82             while len(chunks) > 1:
83                 ins = etree.Element(tagname)
84                 ins.tail = chunks.pop()
85                 parent.insert(ins_index, ins)
86             elem.tail = chunks.pop(0)
87
88
89 def substitute_hyphens(doc):
90     insert_tags(
91         doc,
92         re.compile(r"(?<=[^-\s])-(?=[^-\s])"),
93         "dywiz",
94         exclude=[DCNS("identifier.url"), DCNS("rights.license"), "meta"]
95     )
96
97
98 def fix_hanging(doc):
99     insert_tags(
100         doc,
101         re.compile(r"(?<=\s\w)\s+"),
102         "nbsp",
103         exclude=[DCNS("identifier.url"), DCNS("rights.license")]
104     )
105
106
107 def fix_tables(doc):
108     for kol in doc.iter(tag='kol'):
109         if kol.tail is not None:
110             if not kol.tail.strip():
111                 kol.tail = None
112     for table in chain(doc.iter(tag='tabela'), doc.iter(tag='tabelka')):
113         if table.get('ramka') == '1' or table.get('ramki') == '1':
114             table.set('_format', '|' + 'X|' * len(table[0]))
115         else:
116             table.set('_format', 'X' * len(table[0]))
117
118
119 def mark_subauthors(doc):
120     root_author = ', '.join(
121         elem.text
122         for elem in doc.findall(
123                 './' + RDFNS('RDF') + '//' + DCNS('creator_parsed')
124         )
125     )
126     last_author = None
127     # jeśli autor jest inny niż autor całości i niż poprzedni autor
128     # to wstawiamy jakiś znacznik w rdf?
129     for subutwor in doc.xpath('/utwor/utwor'):
130         author = ', '.join(
131             elem.text
132             for elem in subutwor.findall('.//' + DCNS('creator_parsed'))
133         )
134         if author not in (last_author, root_author):
135             subutwor.find('.//' + RDFNS('RDF')).append(
136                 etree.Element('use_subauthor')
137             )
138         last_author = author
139
140
141 def move_motifs_inside(doc):
142     """ moves motifs to be into block elements """
143     for master in doc.xpath('//powiesc|//opowiadanie|//liryka_l|//liryka_lp|'
144                             '//dramat_wierszowany_l|//dramat_wierszowany_lp|'
145                             '//dramat_wspolczesny'):
146         for motif in master.xpath('motyw'):
147             for sib in motif.itersiblings():
148                 if sib.tag not in ('sekcja_swiatlo', 'sekcja_asterysk',
149                                    'separator_linia', 'begin', 'end',
150                                    'motyw', 'extra', 'uwaga'):
151                     # motif shouldn't have a tail - it would be untagged text
152                     motif.tail = None
153                     motif.getparent().remove(motif)
154                     sib.insert(0, motif)
155                     break
156
157
158 def hack_motifs(doc):
159     """
160     Dirty hack for the marginpar-creates-orphans LaTeX problem
161     see http://www.latex-project.org/cgi-bin/ltxbugs2html?pr=latex/2304
162
163     Moves motifs in stanzas from first verse to second and from next
164     to last to last, then inserts negative vspace before them.
165     """
166     for motif in doc.findall('//strofa//motyw'):
167         # find relevant verse-level tag
168         verse, stanza = motif, motif.getparent()
169         while stanza is not None and stanza.tag != 'strofa':
170             verse, stanza = stanza, stanza.getparent()
171         breaks_before = sum(
172             1 for i in verse.itersiblings('br', preceding=True)
173         )
174         breaks_after = sum(1 for i in verse.itersiblings('br'))
175         if (breaks_before == 0 and breaks_after > 0) or breaks_after == 1:
176             move_by = 1
177             if breaks_after == 2:
178                 move_by += 1
179             moved_motif = deepcopy(motif)
180             motif.tag = 'span'
181             motif.text = None
182             moved_motif.tail = None
183             moved_motif.set('moved', str(move_by))
184
185             for br in verse.itersiblings('br'):
186                 if move_by > 1:
187                     move_by -= 1
188                     continue
189                 br.addnext(moved_motif)
190                 break
191
192
193 def parse_creator(doc):
194     """Generates readable versions of creator and translator tags.
195
196     Finds all dc:creator and dc.contributor.translator tags
197     and adds *_parsed versions with forenames first.
198     """
199     for person in doc.xpath(
200             "|".join('//dc:' + tag for tag in (
201                 'creator', 'contributor.translator'
202             )),
203             namespaces={'dc': str(DCNS)})[::-1]:
204         if not person.text:
205             continue
206         p = Person.from_text(person.text)
207         person_parsed = deepcopy(person)
208         person_parsed.tag = person.tag + '_parsed'
209         person_parsed.set('sortkey', person.text)
210         person_parsed.text = p.readable()
211         person.getparent().insert(0, person_parsed)
212
213
214 def get_stylesheet(name):
215     return get_resource(STYLESHEETS[name])
216
217
218 def package_available(package, args='', verbose=False):
219     """
220     Check if a verion of a latex package accepting given args
221     is available.
222     """
223     tempdir = mkdtemp('-wl2pdf-test')
224     fpath = os.path.join(tempdir, 'test.tex')
225     f = open(fpath, 'w')
226     f.write("""
227         \\documentclass{wl}
228         \\usepackage[%s]{%s}
229         \\begin{document}
230         \\end{document}
231         """ % (args, package))
232     f.close()
233     if verbose:
234         p = call(['xelatex', '-output-directory', tempdir, fpath])
235     else:
236         p = call(
237             ['xelatex', '-interaction=batchmode', '-output-directory',
238              tempdir, fpath],
239             stdout=PIPE, stderr=PIPE
240         )
241     shutil.rmtree(tempdir)
242     return p == 0
243
244
245 def transform(wldoc, verbose=False, save_tex=None, morefloats=None,
246               cover=None, flags=None, customizations=None, base_url='file://./',
247               latex_dir=False):
248     """ produces a PDF file with XeLaTeX
249
250     wldoc: a WLDocument
251     verbose: prints all output from LaTeX
252     save_tex: path to save the intermediary LaTeX file to
253     morefloats (old/new/none): force specific morefloats
254     cover: a cover.Cover factory or True for default
255     flags: less-advertising,
256     customizations: user requested customizations regarding various
257         formatting parameters (passed to wl LaTeX class)
258     """
259
260     # Parse XSLT
261     try:
262         book_info = wldoc.book_info
263         document = load_including_children(wldoc)
264         root = document.edoc.getroot()
265
266         if cover:
267             if cover is True:
268                 cover = make_cover
269             bound_cover = cover(book_info, width=1200)
270             root.set('data-cover-width', str(bound_cover.width))
271             root.set('data-cover-height', str(bound_cover.height))
272             if bound_cover.uses_dc_cover:
273                 if book_info.cover_by:
274                     root.set('data-cover-by', book_info.cover_by)
275                 if book_info.cover_source:
276                     root.set('data-cover-source', book_info.cover_source)
277         if flags:
278             for flag in flags:
279                 root.set('flag-' + flag, 'yes')
280
281         # check for LaTeX packages
282         if morefloats:
283             root.set('morefloats', morefloats.lower())
284         elif package_available('morefloats', 'maxfloats=19'):
285             root.set('morefloats', 'new')
286
287         # add customizations
288         if customizations is not None:
289             root.set('customizations', u','.join(customizations))
290
291         # add editors info
292         editors = document.editors()
293         if editors:
294             root.set('editors', u', '.join(sorted(
295                 editor.readable() for editor in editors)))
296         if document.book_info.funders:
297             root.set('funders', u', '.join(document.book_info.funders))
298         if document.book_info.thanks:
299             root.set('thanks', document.book_info.thanks)
300
301         # hack the tree
302         move_motifs_inside(document.edoc)
303         hack_motifs(document.edoc)
304         parse_creator(document.edoc)
305         substitute_hyphens(document.edoc)
306         fix_hanging(document.edoc)
307         fix_tables(document.edoc)
308         mark_subauthors(document.edoc)
309
310         # wl -> TeXML
311         style_filename = get_stylesheet("wl2tex")
312         style = etree.parse(style_filename)
313         functions.reg_mathml_latex()
314
315         # TeXML -> LaTeX
316         temp = mkdtemp('-wl2pdf')
317
318         for i, ilustr in enumerate(document.edoc.findall('//ilustr')):
319             url = six.moves.urllib.parse.urljoin(
320                 base_url,
321                 ilustr.get('src')
322             )
323             with six.moves.urllib.request.urlopen(url) as imgfile:
324                 img = Image.open(imgfile)
325
326             th_format, ext, media_type = {
327                 'GIF': ('GIF', 'gif', 'image/gif'),
328                 'PNG': ('PNG', 'png', 'image/png'),
329             }.get(img.format, ('JPEG', 'jpg', 'image/jpeg'))
330
331             width = 2400
332             if img.size[0] < width:
333                 th = img
334             else:
335                 th = img.resize((width, round(width * img.size[1] / img.size[0])))
336
337             file_name = 'image%d.%s' % (i, ext)
338             th.save(os.path.join(temp, file_name))
339             ilustr.set('src', file_name)
340
341         for sponsor in book_info.sponsors:
342             ins = etree.Element("data-sponsor", name=sponsor)
343             logo = sponsor_logo(sponsor)
344             if logo:
345                 fname = 'sponsor-%s' % os.path.basename(logo)
346                 shutil.copy(logo, os.path.join(temp, fname))
347                 ins.set('src', fname)
348             root.insert(0, ins)
349
350         if book_info.sponsor_note:
351             root.set("sponsor-note", book_info.sponsor_note)
352
353         texml = document.transform(style)
354
355         if cover:
356             with open(os.path.join(temp, 'cover.png'), 'w') as f:
357                 bound_cover.save(f, quality=80)
358
359         del document  # no longer needed large object :)
360
361         tex_path = os.path.join(temp, 'doc.tex')
362         fout = open(tex_path, 'wb')
363         process(six.BytesIO(texml), fout, 'utf-8')
364         fout.close()
365         del texml
366
367         if save_tex:
368             shutil.copy(tex_path, save_tex)
369
370         # LaTeX -> PDF
371         shutil.copy(get_resource('pdf/wl.cls'), temp)
372         shutil.copy(get_resource('res/wl-logo.png'), temp)
373
374         if latex_dir:
375             return temp
376
377         try:
378             cwd = os.getcwd()
379         except OSError:
380             cwd = None
381         os.chdir(temp)
382
383         # some things work better when compiled twice
384         # (table of contents, [line numbers - disabled])
385         for run in range(2):
386             if verbose:
387                 p = call(['xelatex', tex_path])
388             else:
389                 p = call(
390                     ['xelatex', '-interaction=batchmode', tex_path],
391                     stdout=PIPE, stderr=PIPE
392                 )
393             if p:
394                 raise ParseError("Error parsing .tex file")
395
396         if cwd is not None:
397             os.chdir(cwd)
398
399         output_file = NamedTemporaryFile(prefix='librarian', suffix='.pdf',
400                                          delete=False)
401         pdf_path = os.path.join(temp, 'doc.pdf')
402         shutil.move(pdf_path, output_file.name)
403         shutil.rmtree(temp)
404         return OutputFile.from_filename(output_file.name)
405
406     except (XMLSyntaxError, XSLTApplyError) as e:
407         raise ParseError(e)
408
409
410 def load_including_children(wldoc=None, provider=None, uri=None):
411     """ Makes one big xml file with children inserted at end.
412
413     Either wldoc or provider and URI must be provided.
414     """
415
416     if uri and provider:
417         f = provider.by_uri(uri)
418         text = f.read().decode('utf-8')
419         f.close()
420     elif wldoc is not None:
421         text = etree.tostring(wldoc.edoc, encoding='unicode')
422         provider = wldoc.provider
423     else:
424         raise ValueError(
425             'Neither a WLDocument, nor provider and URI were provided.'
426         )
427
428     text = re.sub(r"([\u0400-\u04ff]+)", r"<alien>\1</alien>", text)
429
430     document = WLDocument.from_bytes(text.encode('utf-8'),
431                                      parse_dublincore=True, provider=provider)
432     document.swap_endlines()
433
434     for child_uri in document.book_info.parts:
435         child = load_including_children(provider=provider, uri=child_uri)
436         document.edoc.getroot().append(child.edoc.getroot())
437     return document