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