Made the license notice a bit shorter in source code. Left the long version in script...
[librarian.git] / librarian / html.py
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright © 2008,2009,2010 Fundacja Nowoczesna Polska  
4 #
5 # This file is part of Librarian, licensed under GNU Affero GPLv3 or later.
6 # For full license text see COPYING or <http://www.gnu.org/licenses/agpl.html>
7 #
8 import os
9 import cStringIO
10 import re
11 import copy
12
13 from lxml import etree
14 from librarian.parser import WLDocument
15 from librarian import XHTMLNS, ParseError
16
17 from lxml.etree import XMLSyntaxError, XSLTApplyError
18
19 ENTITY_SUBSTITUTIONS = [
20     (u'---', u'—'),
21     (u'--', u'–'),
22     (u'...', u'…'),
23     (u',,', u'„'),
24     (u'"', u'”'),
25 ]
26
27 STYLESHEETS = {
28     'legacy': 'xslt/book2html.xslt',
29     'full': 'xslt/wl2html_full.xslt',
30     'partial': 'xslt/wl2html_partial.xslt'
31 }
32
33 def get_stylesheet(name):
34     return os.path.join(os.path.dirname(__file__), STYLESHEETS[name])
35
36 def substitute_entities(context, text):
37     """XPath extension function converting all entites in passed text."""
38     if isinstance(text, list):
39         text = ''.join(text)
40     for entity, substitutution in ENTITY_SUBSTITUTIONS:
41         text = text.replace(entity, substitutution)
42     return text
43
44 # Register substitute_entities function with lxml
45 ns = etree.FunctionNamespace('http://wolnelektury.pl/functions')
46 ns['substitute_entities'] = substitute_entities
47
48 def transform(input, output_filename=None, is_file=True, \
49     parse_dublincore=True, stylesheet='legacy', options={}):
50     """Transforms file input_filename in XML to output_filename in XHTML."""
51     # Parse XSLT
52     try:
53         style_filename = get_stylesheet(stylesheet)
54         style = etree.parse(style_filename)
55
56         if is_file:
57             document = WLDocument.from_file(input, True, \
58                 parse_dublincore=parse_dublincore)
59         else:
60             document = WLDocument.from_string(input, True, \
61                 parse_dublincore=parse_dublincore)
62
63         result = document.transform(style, **options)
64         del document # no longer needed large object :)        
65         
66         if etree.ETXPath('//p|//{%s}p' % str(XHTMLNS))(result) is not None:
67             add_anchors(result.getroot())
68             add_table_of_contents(result.getroot())
69         
70             if output_filename is not None:
71                 result.write(output_filename, xml_declaration=False, pretty_print=True, encoding='utf-8')
72             else:
73                 return result
74             return True
75         else:
76             return "<empty />"
77     except KeyError:
78         raise ValueError("'%s' is not a valid stylesheet.")
79     except (XMLSyntaxError, XSLTApplyError), e:
80         raise ParseError(e)
81
82 class Fragment(object):
83     def __init__(self, id, themes):
84         super(Fragment, self).__init__()
85         self.id = id
86         self.themes = themes
87         self.events = []
88
89     def append(self, event, element):
90         self.events.append((event, element))
91
92     def closed_events(self):
93         stack = []
94         for event, element in self.events:
95             if event == 'start':
96                 stack.append(('end', element))
97             elif event == 'end':
98                 try:
99                     stack.pop()
100                 except IndexError:
101                     print 'CLOSED NON-OPEN TAG:', element
102
103         stack.reverse()
104         return self.events + stack
105
106     def to_string(self):
107         result = []
108         for event, element in self.closed_events():
109             if event == 'start':
110                 result.append(u'<%s %s>' % (element.tag, ' '.join('%s="%s"' % (k, v) for k, v in element.attrib.items())))
111                 if element.text:
112                     result.append(element.text)
113             elif event == 'end':
114                 result.append(u'</%s>' % element.tag)
115                 if element.tail:
116                     result.append(element.tail)
117             else:
118                 result.append(element)
119
120         return ''.join(result)
121
122     def __unicode__(self):
123         return self.to_string()
124
125
126 def extract_fragments(input_filename):
127     """Extracts theme fragments from input_filename."""
128     open_fragments = {}
129     closed_fragments = {}
130
131     for event, element in etree.iterparse(input_filename, events=('start', 'end')):
132         # Process begin and end elements
133         if element.get('class', '') in ('theme-begin', 'theme-end'):
134             if not event == 'end': continue # Process elements only once, on end event
135
136             # Open new fragment
137             if element.get('class', '') == 'theme-begin':
138                 fragment = Fragment(id=element.get('fid'), themes=element.text)
139
140                 # Append parents
141                 if element.getparent().get('id', None) != 'book-text':
142                     parents = [element.getparent()]
143                     while parents[-1].getparent().get('id', None) != 'book-text':
144                         parents.append(parents[-1].getparent())
145
146                     parents.reverse()
147                     for parent in parents:
148                         fragment.append('start', parent)
149
150                 open_fragments[fragment.id] = fragment
151
152             # Close existing fragment
153             else:
154                 try:
155                     fragment = open_fragments[element.get('fid')]
156                 except KeyError:
157                     print '%s:closed not open fragment #%s' % (input_filename, element.get('fid'))
158                 else:
159                     closed_fragments[fragment.id] = fragment
160                     del open_fragments[fragment.id]
161
162             # Append element tail to lost_text (we don't want to lose any text)
163             if element.tail:
164                 for fragment_id in open_fragments:
165                     open_fragments[fragment_id].append('text', element.tail)
166
167
168         # Process all elements except begin and end
169         else:
170             # Omit annotation tags
171             if len(element.get('name', '')) or element.get('class', '') == 'annotation':
172                 if event == 'end' and element.tail:
173                     for fragment_id in open_fragments:
174                         open_fragments[fragment_id].append('text', element.tail)
175             else:
176                 for fragment_id in open_fragments:
177                     open_fragments[fragment_id].append(event, copy.copy(element))
178
179     return closed_fragments, open_fragments
180
181
182 def add_anchor(element, prefix, with_link=True, with_target=True, link_text=None):
183     if with_link:
184         if link_text is None:
185             link_text = prefix
186         anchor = etree.Element('a', href='#%s' % prefix)
187         anchor.set('class', 'anchor')
188         anchor.text = unicode(link_text)
189         if element.text:
190             anchor.tail = element.text
191             element.text = u''
192         element.insert(0, anchor)
193     
194     if with_target:
195         anchor_target = etree.Element('a', name='%s' % prefix)
196         anchor_target.set('class', 'target')
197         anchor_target.text = u' '
198         if element.text:
199             anchor_target.tail = element.text
200             element.text = u''
201         element.insert(0, anchor_target)
202
203
204 def any_ancestor(element, test):
205     for ancestor in element.iterancestors():
206         if test(ancestor):
207             return True
208     return False
209
210
211 def add_anchors(root):
212     counter = 1
213     for element in root.iterdescendants():
214         if any_ancestor(element, lambda e: e.get('class') in ('note', 'motto', 'motto_podpis', 'dedication')
215         or e.tag == 'blockquote'):
216             continue
217         
218         if element.tag == 'p' and 'verse' in element.get('class', ''):
219             if counter == 1 or counter % 5 == 0:
220                 add_anchor(element, "f%d" % counter, link_text=counter)
221             counter += 1
222         elif 'paragraph' in element.get('class', ''):
223             add_anchor(element, "f%d" % counter, link_text=counter)
224             counter += 1
225
226
227 def add_table_of_contents(root):
228     sections = []
229     counter = 1
230     for element in root.iterdescendants():
231         if element.tag in ('h2', 'h3'):
232             if any_ancestor(element, lambda e: e.get('id') in ('footnotes',) or e.get('class') in ('person-list',)):
233                 continue
234             
235             if element.tag == 'h3' and len(sections) and sections[-1][1] == 'h2':
236                 sections[-1][3].append((counter, element.tag, ''.join(element.xpath('text()')), []))
237             else:
238                 sections.append((counter, element.tag, ''.join(element.xpath('text()')), []))
239             add_anchor(element, "s%d" % counter, with_link=False)
240             counter += 1
241     
242     toc = etree.Element('div')
243     toc.set('id', 'toc')
244     toc_header = etree.SubElement(toc, 'h2')
245     toc_header.text = u'Spis treści'
246     toc_list = etree.SubElement(toc, 'ol')
247
248     for n, section, text, subsections in sections:
249         section_element = etree.SubElement(toc_list, 'li')
250         add_anchor(section_element, "s%d" % n, with_target=False, link_text=text)
251         
252         if len(subsections):
253             subsection_list = etree.SubElement(section_element, 'ol')
254             for n, subsection, text, _ in subsections:
255                 subsection_element = etree.SubElement(subsection_list, 'li')
256                 add_anchor(subsection_element, "s%d" % n, with_target=False, link_text=text)
257     
258     root.insert(0, toc)
259