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