90e4dc22fdb4d709e6fcd9fa449fd2f8e2c87fa7
[librarian.git] / 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 with_statement
13 import os
14 import os.path
15 import shutil
16 from StringIO import StringIO
17 from tempfile import mkdtemp, NamedTemporaryFile
18 import re
19 from copy import deepcopy
20 from subprocess import call, PIPE
21
22 from Texml.processor import process
23 from lxml import etree
24 from lxml.etree import XMLSyntaxError, XSLTApplyError
25
26 from librarian.dcparser import Person
27 from librarian.parser import WLDocument
28 from librarian import ParseError, DCNS, get_resource, OutputFile
29 from librarian import functions
30 from librarian.cover import WLCover
31
32
33 functions.reg_substitute_entities()
34 functions.reg_strip()
35 functions.reg_starts_white()
36 functions.reg_ends_white()
37 functions.reg_texcommand()
38
39 STYLESHEETS = {
40     'wl2tex': 'pdf/wl2tex.xslt',
41 }
42
43 #CUSTOMIZATIONS = [
44 #    'nofootnotes',
45 #    'nothemes',
46 #    'defaultleading',
47 #    'onehalfleading',
48 #    'doubleleading',
49 #    'nowlfont',
50 #    ]
51
52 def insert_tags(doc, split_re, tagname, exclude=None):
53     """ inserts <tagname> for every occurence of `split_re' in text nodes in the `doc' tree
54
55     >>> t = etree.fromstring('<a><b>A-B-C</b>X-Y-Z</a>');
56     >>> insert_tags(t, re.compile('-'), 'd');
57     >>> print etree.tostring(t)
58     <a><b>A<d/>B<d/>C</b>X<d/>Y<d/>Z</a>
59     """
60
61     for elem in doc.iter(tag=etree.Element):
62         if exclude and elem.tag in exclude:
63             continue
64         if elem.text:
65             chunks = split_re.split(elem.text)
66             while len(chunks) > 1:
67                 ins = etree.Element(tagname)
68                 ins.tail = chunks.pop()
69                 elem.insert(0, ins)
70             elem.text = chunks.pop(0)
71         if elem.tail:
72             chunks = split_re.split(elem.tail)
73             parent = elem.getparent()
74             ins_index = parent.index(elem) + 1
75             while len(chunks) > 1:
76                 ins = etree.Element(tagname)
77                 ins.tail = chunks.pop()
78                 parent.insert(ins_index, ins)
79             elem.tail = chunks.pop(0)
80
81
82 def substitute_hyphens(doc):
83     insert_tags(doc,
84                 re.compile("(?<=[^-\s])-(?=[^-\s])"),
85                 "dywiz",
86                 exclude=[DCNS("identifier.url"), DCNS("rights.license")]
87                 )
88
89
90 def fix_hanging(doc):
91     insert_tags(doc,
92                 re.compile("(?<=\s\w)\s+"),
93                 "nbsp",
94                 exclude=[DCNS("identifier.url"), DCNS("rights.license")]
95                 )
96
97
98 def move_motifs_inside(doc):
99     """ moves motifs to be into block elements """
100     for master in doc.xpath('//powiesc|//opowiadanie|//liryka_l|//liryka_lp|//dramat_wierszowany_l|//dramat_wierszowany_lp|//dramat_wspolczesny'):
101         for motif in master.xpath('motyw'):
102             for sib in motif.itersiblings():
103                 if sib.tag not in ('sekcja_swiatlo', 'sekcja_asterysk', 'separator_linia', 'begin', 'end', 'motyw', 'extra', 'uwaga'):
104                     # motif shouldn't have a tail - it would be untagged text
105                     motif.tail = None
106                     motif.getparent().remove(motif)
107                     sib.insert(0, motif)
108                     break
109
110
111 def hack_motifs(doc):
112     """ dirty hack for the marginpar-creates-orphans LaTeX problem
113     see http://www.latex-project.org/cgi-bin/ltxbugs2html?pr=latex/2304
114
115     moves motifs in stanzas from first verse to second
116     and from next to last to last, then inserts negative vspace before them
117     """
118     for motif in doc.findall('//strofa//motyw'):
119         # find relevant verse-level tag
120         verse, stanza = motif, motif.getparent()
121         while stanza is not None and stanza.tag != 'strofa':
122             verse, stanza = stanza, stanza.getparent()
123         breaks_before = sum(1 for i in verse.itersiblings('br', preceding=True))
124         breaks_after = sum(1 for i in verse.itersiblings('br'))
125         if (breaks_before == 0 and breaks_after > 0) or breaks_after == 1:
126             move_by = 1
127             if breaks_after == 2:
128                 move_by += 1
129             moved_motif = deepcopy(motif)
130             motif.tag = 'span'
131             motif.text = None
132             moved_motif.tail = None
133             moved_motif.set('moved', str(move_by))
134
135             for br in verse.itersiblings('br'):
136                 if move_by > 1:
137                     move_by -= 1
138                     continue
139                 br.addnext(moved_motif)
140                 break
141
142
143 def parse_creator(doc):
144     """Generates readable versions of creator and translator tags.
145
146     Finds all dc:creator and dc.contributor.translator tags
147     and adds *_parsed versions with forenames first.
148     """
149     for person in doc.xpath("|".join('//dc:'+(tag) for tag in (
150                     'creator', 'contributor.translator')),
151                     namespaces = {'dc': str(DCNS)})[::-1]:
152         if not person.text:
153             continue
154         p = Person.from_text(person.text)
155         person_parsed = deepcopy(person)
156         person_parsed.tag = person.tag + '_parsed'
157         person_parsed.set('sortkey', person.text)
158         person_parsed.text = p.readable()
159         person.getparent().insert(0, person_parsed)
160
161
162 def get_stylesheet(name):
163     return get_resource(STYLESHEETS[name])
164
165
166 def package_available(package, args='', verbose=False):
167     """ check if a verion of a latex package accepting given args is available """
168     tempdir = mkdtemp('-wl2pdf-test')
169     fpath = os.path.join(tempdir, 'test.tex')
170     f = open(fpath, 'w')
171     f.write(r"""
172         \documentclass{wl}
173         \usepackage[%s]{%s}
174         \begin{document}
175         \end{document}
176         """ % (args, package))
177     f.close()
178     if verbose:
179         p = call(['xelatex', '-output-directory', tempdir, fpath])
180     else:
181         p = call(['xelatex', '-interaction=batchmode', '-output-directory', tempdir, fpath], stdout=PIPE, stderr=PIPE)
182     shutil.rmtree(tempdir)
183     return p == 0
184
185
186 def transform(wldoc, verbose=False, save_tex=None, morefloats=None,
187               cover=None, flags=None, customizations=None):
188     """ produces a PDF file with XeLaTeX
189
190     wldoc: a WLDocument
191     verbose: prints all output from LaTeX
192     save_tex: path to save the intermediary LaTeX file to
193     morefloats (old/new/none): force specific morefloats
194     cover: a cover.Cover factory or True for default
195     flags: less-advertising,
196     customizations: user requested customizations regarding various formatting parameters (passed to wl LaTeX class)
197     """
198
199     # Parse XSLT
200     try:
201         book_info = wldoc.book_info
202         document = load_including_children(wldoc)
203         root = document.edoc.getroot()
204
205         if cover:
206             if cover is True:
207                 cover = WLCover
208             bound_cover = cover(book_info)
209             root.set('data-cover-width', str(bound_cover.width))
210             root.set('data-cover-height', str(bound_cover.height))
211             if bound_cover.uses_dc_cover:
212                 if book_info.cover_by:
213                     root.set('data-cover-by', book_info.cover_by)
214                 if book_info.cover_source:
215                     root.set('data-cover-source',
216                             book_info.cover_source)
217         if flags:
218             for flag in flags:
219                 root.set('flag-' + flag, 'yes')
220
221         # check for LaTeX packages
222         if morefloats:
223             root.set('morefloats', morefloats.lower())
224         elif package_available('morefloats', 'maxfloats=19'):
225             root.set('morefloats', 'new')
226
227         # add customizations
228         if customizations is not None:
229             root.set('customizations', u','.join(customizations))
230
231         # add editors info
232         root.set('editors', u', '.join(sorted(
233             editor.readable() for editor in document.editors())))
234         if document.book_info.funders:
235             root.set('funders', u', '.join(document.book_info.funders))
236
237         # hack the tree
238         move_motifs_inside(document.edoc)
239         hack_motifs(document.edoc)
240         parse_creator(document.edoc)
241         substitute_hyphens(document.edoc)
242         fix_hanging(document.edoc)
243
244         # wl -> TeXML
245         style_filename = get_stylesheet("wl2tex")
246         style = etree.parse(style_filename)
247
248         texml = document.transform(style)
249
250         # TeXML -> LaTeX
251         temp = mkdtemp('-wl2pdf')
252
253         if cover:
254             with open(os.path.join(temp, 'cover.png'), 'w') as f:
255                 bound_cover.save(f)
256
257         del document # no longer needed large object :)
258
259         tex_path = os.path.join(temp, 'doc.tex')
260         fout = open(tex_path, 'w')
261         process(StringIO(texml), fout, 'utf-8')
262         fout.close()
263         del texml
264
265         if save_tex:
266             shutil.copy(tex_path, save_tex)
267
268         # LaTeX -> PDF
269         shutil.copy(get_resource('pdf/wl.cls'), temp)
270         shutil.copy(get_resource('res/wl-logo.png'), temp)
271
272         try:
273             cwd = os.getcwd()
274         except OSError:
275             cwd = None
276         os.chdir(temp)
277
278         if verbose:
279             p = call(['xelatex', tex_path])
280         else:
281             p = call(['xelatex', '-interaction=batchmode', tex_path], stdout=PIPE, stderr=PIPE)
282         if p:
283             raise ParseError("Error parsing .tex file")
284
285         if cwd is not None:
286             os.chdir(cwd)
287
288         output_file = NamedTemporaryFile(prefix='librarian', suffix='.pdf', delete=False)
289         pdf_path = os.path.join(temp, 'doc.pdf')
290         shutil.move(pdf_path, output_file.name)
291         shutil.rmtree(temp)
292         return OutputFile.from_filename(output_file.name)
293
294     except (XMLSyntaxError, XSLTApplyError), e:
295         raise ParseError(e)
296
297
298 def load_including_children(wldoc=None, provider=None, uri=None):
299     """ Makes one big xml file with children inserted at end.
300     
301     Either wldoc or provider and URI must be provided.
302     """
303
304     if uri and provider:
305         f = provider.by_uri(uri)
306         text = f.read().decode('utf-8')
307         f.close()
308     elif wldoc is not None:
309         text = etree.tostring(wldoc.edoc, encoding=unicode)
310         provider = wldoc.provider
311     else:
312         raise ValueError('Neither a WLDocument, nor provider and URI were provided.')
313
314     text = re.sub(ur"([\u0400-\u04ff]+)", ur"<alien>\1</alien>", text)
315
316     document = WLDocument.from_string(text,
317                 parse_dublincore=True, provider=provider)
318     document.swap_endlines()
319
320     for child_uri in document.book_info.parts:
321         child = load_including_children(provider=provider, uri=child_uri)
322         document.edoc.getroot().append(child.edoc.getroot())
323     return document