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