b8aafdbf5ce3a39f17c6ae47fcfa0f70281c84d5
[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         document = load_including_children(wldoc)
202         root = document.edoc.getroot()
203
204         if cover:
205             if cover is True:
206                 cover = WLCover
207             bound_cover = cover(document.book_info)
208             root.set('data-cover-width', str(bound_cover.width))
209             root.set('data-cover-height', str(bound_cover.height))
210             if bound_cover.uses_dc_cover:
211                 if document.book_info.cover_by:
212                     root.set('data-cover-by', document.book_info.cover_by)
213                 if document.book_info.cover_source:
214                     root.set('data-cover-source',
215                             document.book_info.cover_source)
216         if flags:
217             for flag in flags:
218                 root.set('flag-' + flag, 'yes')
219
220         # check for LaTeX packages
221         if morefloats:
222             root.set('morefloats', morefloats.lower())
223         elif package_available('morefloats', 'maxfloats=19'):
224             root.set('morefloats', 'new')
225
226         # add customizations
227         if customizations is not None:
228             root.set('customizations', u','.join(customizations))
229
230         # add editors info
231         root.set('editors', u', '.join(sorted(
232             editor.readable() for editor in document.editors())))
233
234         # hack the tree
235         move_motifs_inside(document.edoc)
236         hack_motifs(document.edoc)
237         parse_creator(document.edoc)
238         substitute_hyphens(document.edoc)
239         fix_hanging(document.edoc)
240
241         # wl -> TeXML
242         style_filename = get_stylesheet("wl2tex")
243         style = etree.parse(style_filename)
244
245         texml = document.transform(style)
246
247         # TeXML -> LaTeX
248         temp = mkdtemp('-wl2pdf')
249
250         if cover:
251             with open(os.path.join(temp, 'cover.png'), 'w') as f:
252                 bound_cover.save(f)
253
254         del document # no longer needed large object :)
255
256         tex_path = os.path.join(temp, 'doc.tex')
257         fout = open(tex_path, 'w')
258         process(StringIO(texml), fout, 'utf-8')
259         fout.close()
260         del texml
261
262         if save_tex:
263             shutil.copy(tex_path, save_tex)
264
265         # LaTeX -> PDF
266         shutil.copy(get_resource('pdf/wl.cls'), temp)
267         shutil.copy(get_resource('res/wl-logo.png'), temp)
268
269         try:
270             cwd = os.getcwd()
271         except OSError:
272             cwd = None
273         os.chdir(temp)
274
275         if verbose:
276             p = call(['xelatex', tex_path])
277         else:
278             p = call(['xelatex', '-interaction=batchmode', tex_path], stdout=PIPE, stderr=PIPE)
279         if p:
280             raise ParseError("Error parsing .tex file")
281
282         if cwd is not None:
283             os.chdir(cwd)
284
285         output_file = NamedTemporaryFile(prefix='librarian', suffix='.pdf', delete=False)
286         pdf_path = os.path.join(temp, 'doc.pdf')
287         shutil.move(pdf_path, output_file.name)
288         shutil.rmtree(temp)
289         return OutputFile.from_filename(output_file.name)
290
291     except (XMLSyntaxError, XSLTApplyError), e:
292         raise ParseError(e)
293
294
295 def load_including_children(wldoc=None, provider=None, uri=None):
296     """ Makes one big xml file with children inserted at end.
297     
298     Either wldoc or provider and URI must be provided.
299     """
300
301     if uri and provider:
302         f = provider.by_uri(uri)
303         text = f.read().decode('utf-8')
304         f.close()
305     elif wldoc is not None:
306         text = etree.tostring(wldoc.edoc, encoding=unicode)
307         provider = wldoc.provider
308     else:
309         raise ValueError('Neither a WLDocument, nor provider and URI were provided.')
310
311     text = re.sub(ur"([\u0400-\u04ff]+)", ur"<alien>\1</alien>", text)
312
313     document = WLDocument.from_string(text,
314                 parse_dublincore=True, provider=provider)
315     document.swap_endlines()
316
317     for child_uri in document.book_info.parts:
318         child = load_including_children(provider=provider, uri=child_uri)
319         document.edoc.getroot().append(child.edoc.getroot())
320     return document