7889a2284c5299c09f6203e8f307a92007c15b08
[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, IOFile, Format
29 from librarian import functions
30
31
32 functions.reg_substitute_entities()
33 functions.reg_strip()
34 functions.reg_starts_white()
35 functions.reg_ends_white()
36 functions.reg_texcommand()
37
38 STYLESHEETS = {
39     'wl2tex': 'pdf/wl2tex.xslt',
40 }
41
42 def insert_tags(doc, split_re, tagname, exclude=None):
43     """ inserts <tagname> for every occurence of `split_re' in text nodes in the `doc' tree
44
45     >>> t = etree.fromstring('<a><b>A-B-C</b>X-Y-Z</a>');
46     >>> insert_tags(t, re.compile('-'), 'd');
47     >>> print etree.tostring(t)
48     <a><b>A<d/>B<d/>C</b>X<d/>Y<d/>Z</a>
49     """
50
51     for elem in doc.iter(tag=etree.Element):
52         if exclude and elem.tag in exclude:
53             continue
54         if elem.text:
55             chunks = split_re.split(elem.text)
56             while len(chunks) > 1:
57                 ins = etree.Element(tagname)
58                 ins.tail = chunks.pop()
59                 elem.insert(0, ins)
60             elem.text = chunks.pop(0)
61         if elem.tail:
62             chunks = split_re.split(elem.tail)
63             parent = elem.getparent()
64             ins_index = parent.index(elem) + 1
65             while len(chunks) > 1:
66                 ins = etree.Element(tagname)
67                 ins.tail = chunks.pop()
68                 parent.insert(ins_index, ins)
69             elem.tail = chunks.pop(0)
70
71
72 def substitute_hyphens(doc):
73     insert_tags(doc,
74                 re.compile("(?<=[^-\s])-(?=[^-\s])"),
75                 "dywiz",
76                 exclude=[DCNS("identifier.url"), DCNS("rights.license"), 'www']
77                 )
78
79
80 def fix_hanging(doc):
81     insert_tags(doc,
82                 re.compile("(?<=\s\w)\s+"),
83                 "nbsp",
84                 exclude=[DCNS("identifier.url"), DCNS("rights.license")]
85                 )
86
87
88 def move_motifs_inside(doc):
89     """ moves motifs to be into block elements """
90     for master in doc.xpath('//powiesc|//opowiadanie|//liryka_l|//liryka_lp|//dramat_wierszowany_l|//dramat_wierszowany_lp|//dramat_wspolczesny'):
91         for motif in master.xpath('motyw'):
92             for sib in motif.itersiblings():
93                 if sib.tag not in ('sekcja_swiatlo', 'sekcja_asterysk', 'separator_linia', 'begin', 'end', 'motyw', 'extra', 'uwaga'):
94                     # motif shouldn't have a tail - it would be untagged text
95                     motif.tail = None
96                     motif.getparent().remove(motif)
97                     sib.insert(0, motif)
98                     break
99
100
101 def hack_motifs(doc):
102     """ dirty hack for the marginpar-creates-orphans LaTeX problem
103     see http://www.latex-project.org/cgi-bin/ltxbugs2html?pr=latex/2304
104
105     moves motifs in stanzas from first verse to second
106     and from next to last to last, then inserts negative vspace before them
107     """
108     for motif in doc.findall('//strofa//motyw'):
109         # find relevant verse-level tag
110         verse, stanza = motif, motif.getparent()
111         while stanza is not None and stanza.tag != 'strofa':
112             verse, stanza = stanza, stanza.getparent()
113         breaks_before = sum(1 for i in verse.itersiblings('br', preceding=True))
114         breaks_after = sum(1 for i in verse.itersiblings('br'))
115         if (breaks_before == 0 and breaks_after > 0) or breaks_after == 1:
116             move_by = 1
117             if breaks_after == 2:
118                 move_by += 1
119             moved_motif = deepcopy(motif)
120             motif.tag = 'span'
121             motif.text = None
122             moved_motif.tail = None
123             moved_motif.set('moved', str(move_by))
124
125             for br in verse.itersiblings('br'):
126                 if move_by > 1:
127                     move_by -= 1
128                     continue
129                 br.addnext(moved_motif)
130                 break
131
132
133 def parse_creator(doc):
134     """Generates readable versions of creator and translator tags.
135
136     Finds all dc:creator and dc.contributor.translator tags
137     and adds *_parsed versions with forenames first.
138     """
139     for person in doc.xpath("|".join('//dc:'+(tag) for tag in (
140                     'creator', 'contributor.translator')),
141                     namespaces = {'dc': str(DCNS)})[::-1]:
142         if not person.text:
143             continue
144         p = Person.from_text(person.text)
145         person_parsed = deepcopy(person)
146         person_parsed.tag = person.tag + '_parsed'
147         person_parsed.set('sortkey', person.text)
148         person_parsed.text = p.readable()
149         person.getparent().insert(0, person_parsed)
150
151
152 def get_stylesheet(name):
153     return get_resource(STYLESHEETS[name])
154
155
156 def package_available(package, args='', verbose=False):
157     """ check if a verion of a latex package accepting given args is available """
158     tempdir = mkdtemp('-wl2pdf-test')
159     fpath = os.path.join(tempdir, 'test.tex')
160     f = open(fpath, 'w')
161     f.write(r"""
162         \documentclass{wl}
163         \usepackage[%s]{%s}
164         \begin{document}
165         \end{document}
166         """ % (args, package))
167     f.close()
168     if verbose:
169         p = call(['xelatex', '-output-directory', tempdir, fpath])
170     else:
171         p = call(['xelatex', '-interaction=batchmode', '-output-directory', tempdir, fpath], stdout=PIPE, stderr=PIPE)
172     shutil.rmtree(tempdir)
173     return p == 0
174
175
176 # not used
177 def load_including_children(wldoc=None, provider=None, uri=None):
178     """ Makes one big xml file with children inserted at end.
179
180     Either wldoc or provider and URI must be provided.
181     """
182
183     if uri and provider:
184         f = provider.by_uri(uri)
185         # WTF DocProvider.by_uri() returns IOFile, so no .read() there
186         text = f.read().decode('utf-8')
187         f.close()
188     elif wldoc is not None:
189         text = etree.tostring(wldoc.edoc, encoding=unicode)
190         provider = wldoc.provider
191     else:
192         raise ValueError('Neither a WLDocument, nor provider and URI were provided.')
193
194     text = re.sub(ur"([\u0400-\u04ff]+)", ur"<alien>\1</alien>", text)
195
196     document = WLDocument.from_string(text,
197                 parse_dublincore=True, provider=provider)
198     document.swap_endlines()
199
200     for child_uri in document.book_info.parts:
201         child = load_including_children(provider=provider, uri=child_uri)
202         document.edoc.getroot().append(child.edoc.getroot())
203     return document
204
205
206 class PDFFormat(Format):
207     """ Base PDF format.
208
209     Available customization:
210         nofootnotes: Doesn't do footnotes.
211         nothemes: Doesn't do themes.
212         defaultleading: Default leading.
213         onehalfleading: Bigger leading.
214         doubleleading: Big leading.
215         nowlfont: Uses standard TeX font instead of JUnicodeWL.
216
217     """
218
219     cover_class = None
220     tex_passes = 1
221     style = get_resource('pdf/default.sty')
222     cover = None
223
224     @property
225     def has_cover(self):
226         """ For use in XSLT. """
227         return self.cover is not None
228
229     @property
230     def customization_str(self):
231         """ For use in XSLT. """
232         return u','.join(k for k, v in self.customization.items() if v)
233
234     def get_texml(self):
235         raise NotImplementedError
236
237     def get_tex_dir(self):
238         texml = self.get_texml()
239         temp = mkdtemp('-wl2pdf')
240         # Save TeX file
241         tex_path = os.path.join(temp, 'doc.tex')
242         with open(tex_path, 'w') as fout:
243             process(StringIO(texml), fout, 'utf-8')
244         if self.save_tex:
245             shutil.copy(tex_path, self.save_tex)
246         # Copy style
247         shutil.copy(get_resource('pdf/wl.cls'), temp)
248         shutil.copy(self.style, os.path.join(temp, 'style.sty'))
249         #for sfile in ['wasysym.sty', 'uwasyvar.fd', 'uwasy.fd']:
250         #    shutil.copy(get_resource(os.path.join('res/wasysym', sfile)), temp)
251
252         # Save attachments
253         if self.cover:
254             self.cover.for_pdf().dump_to(os.path.join(temp, 'makecover.sty'))
255         return temp
256
257     def get_pdf(self):
258         temp = self.get_tex_dir()
259         tex_path = os.path.join(temp, 'doc.tex')
260         try:
261             cwd = os.getcwd()
262         except OSError:
263             cwd = None
264         os.chdir(temp)
265
266         if self.verbose:
267             for i in range(self.tex_passes):
268                 p = call(['xelatex', tex_path])
269         else:
270             for i in range(self.tex_passes):
271                 p = call(['xelatex', '-interaction=batchmode', tex_path],
272                             stdout=PIPE, stderr=PIPE)
273         if p:
274             raise ParseError("Error parsing .tex file: %s" % tex_path)
275
276         if cwd is not None:
277             os.chdir(cwd)
278
279         output_file = NamedTemporaryFile(prefix='librarian', suffix='.pdf', delete=False)
280         pdf_path = os.path.join(temp, 'doc.pdf')
281         shutil.move(pdf_path, output_file.name)
282         shutil.rmtree(temp)
283         return IOFile.from_filename(output_file.name)
284
285     def build(self, verbose=False, save_tex=None, morefloats=None):
286         """ morefloats: new/old/none
287         """
288         self.verbose = verbose
289         self.save_tex = save_tex
290
291         if morefloats is None and package_available('morefloats', 'maxfloats=19'):
292             morefloats = 'new'
293         self.morefloats = morefloats
294
295         book_info = self.wldoc.book_info
296         if self.cover_class:
297             self.cover = self.cover_class(book_info)
298
299         return self.get_pdf()