8822f960732108ac762249f527039295f6e584ff
[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 copy
9
10 from lxml import etree
11 from librarian import XHTMLNS, ParseError, OutputFile
12 from librarian import functions
13
14 from lxml.etree import XMLSyntaxError, XSLTApplyError
15
16 functions.reg_substitute_entities()
17 functions.reg_person_name()
18
19 STYLESHEETS = {
20     'legacy': 'xslt/book2html.xslt',
21     'full': 'xslt/wl2html_full.xslt',
22     'partial': 'xslt/wl2html_partial.xslt'
23 }
24
25 def get_stylesheet(name):
26     return os.path.join(os.path.dirname(__file__), STYLESHEETS[name])
27
28 def html_has_content(text):
29     return etree.ETXPath('//p|//{%(ns)s}p|//h1|//{%(ns)s}h1' % {'ns': str(XHTMLNS)})(text)
30
31 def transform(wldoc, stylesheet='legacy', options=None, flags=None):
32     """Transforms the WL document to XHTML.
33
34     If output_filename is None, returns an XML,
35     otherwise returns True if file has been written,False if it hasn't.
36     File won't be written if it has no content.
37     """
38     # Parse XSLT
39     try:
40         style_filename = get_stylesheet(stylesheet)
41         style = etree.parse(style_filename)
42
43         document = copy.deepcopy(wldoc)
44         del wldoc
45         document.swap_endlines()
46
47         if flags:
48             for flag in flags:
49                 document.edoc.getroot().set(flag, 'yes')
50
51         document.clean_ed_note()
52
53         if not options:
54             options = {}
55         result = document.transform(style, **options)
56         del document # no longer needed large object :)
57
58         if html_has_content(result):
59             add_anchors(result.getroot())
60             add_table_of_themes(result.getroot())
61             add_table_of_contents(result.getroot())
62
63             return OutputFile.from_string(etree.tostring(result, method='html',
64                 xml_declaration=False, pretty_print=True, encoding='utf-8'))
65         else:
66             return None
67     except KeyError:
68         raise ValueError("'%s' is not a valid stylesheet.")
69     except (XMLSyntaxError, XSLTApplyError), e:
70         raise ParseError(e)
71
72 class Fragment(object):
73     def __init__(self, id, themes):
74         super(Fragment, self).__init__()
75         self.id = id
76         self.themes = themes
77         self.events = []
78
79     def append(self, event, element):
80         self.events.append((event, element))
81
82     def closed_events(self):
83         stack = []
84         for event, element in self.events:
85             if event == 'start':
86                 stack.append(('end', element))
87             elif event == 'end':
88                 try:
89                     stack.pop()
90                 except IndexError:
91                     print 'CLOSED NON-OPEN TAG:', element
92
93         stack.reverse()
94         return self.events + stack
95
96     def to_string(self):
97         result = []
98         for event, element in self.closed_events():
99             if event == 'start':
100                 result.append(u'<%s %s>' % (element.tag, ' '.join('%s="%s"' % (k, v) for k, v in element.attrib.items())))
101                 if element.text:
102                     result.append(element.text)
103             elif event == 'end':
104                 result.append(u'</%s>' % element.tag)
105                 if element.tail:
106                     result.append(element.tail)
107             else:
108                 result.append(element)
109
110         return ''.join(result)
111
112     def __unicode__(self):
113         return self.to_string()
114
115
116 def extract_fragments(input_filename):
117     """Extracts theme fragments from input_filename."""
118     open_fragments = {}
119     closed_fragments = {}
120
121     # iterparse would die on a HTML document
122     parser = etree.HTMLParser(encoding='utf-8')
123     buf = cStringIO.StringIO()
124     buf.write(etree.tostring(etree.parse(input_filename, parser).getroot()[0][0], encoding='utf-8'))
125     buf.seek(0)
126
127     for event, element in etree.iterparse(buf, events=('start', 'end')):
128         # Process begin and end elements
129         if element.get('class', '') in ('theme-begin', 'theme-end'):
130             if not event == 'end': continue # Process elements only once, on end event
131
132             # Open new fragment
133             if element.get('class', '') == 'theme-begin':
134                 fragment = Fragment(id=element.get('fid'), themes=element.text)
135
136                 # Append parents
137                 if element.getparent().get('id', None) != 'book-text':
138                     parents = [element.getparent()]
139                     while parents[-1].getparent().get('id', None) != 'book-text':
140                         parents.append(parents[-1].getparent())
141
142                     parents.reverse()
143                     for parent in parents:
144                         fragment.append('start', parent)
145
146                 open_fragments[fragment.id] = fragment
147
148             # Close existing fragment
149             else:
150                 try:
151                     fragment = open_fragments[element.get('fid')]
152                 except KeyError:
153                     print '%s:closed not open fragment #%s' % (input_filename, element.get('fid'))
154                 else:
155                     closed_fragments[fragment.id] = fragment
156                     del open_fragments[fragment.id]
157
158             # Append element tail to lost_text (we don't want to lose any text)
159             if element.tail:
160                 for fragment_id in open_fragments:
161                     open_fragments[fragment_id].append('text', element.tail)
162
163
164         # Process all elements except begin and end
165         else:
166             # Omit annotation tags
167             if (len(element.get('name', '')) or 
168                     element.get('class', '') in ('annotation', 'anchor')):
169                 if event == 'end' and element.tail:
170                     for fragment_id in open_fragments:
171                         open_fragments[fragment_id].append('text', element.tail)
172             else:
173                 for fragment_id in open_fragments:
174                     open_fragments[fragment_id].append(event, copy.copy(element))
175
176     return closed_fragments, open_fragments
177
178
179 def add_anchor(element, prefix, with_link=True, with_target=True, link_text=None):
180     parent = element.getparent()
181     index = parent.index(element)
182
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         parent.insert(index, anchor)
190
191     if with_target:
192         anchor_target = etree.Element('a', name='%s' % prefix)
193         anchor_target.set('class', 'target')
194         anchor_target.text = u' '
195         parent.insert(index, anchor_target)
196
197
198 def any_ancestor(element, test):
199     for ancestor in element.iterancestors():
200         if test(ancestor):
201             return True
202     return False
203
204
205 def add_anchors(root):
206     counter = 1
207     for element in root.iterdescendants():
208         if any_ancestor(element, lambda e: e.get('class') in ('note', 'motto', 'motto_podpis', 'dedication')
209         or e.get('id') == 'nota_red'
210         or e.tag == 'blockquote'):
211             continue
212
213         if element.tag == 'p' and 'verse' in element.get('class', ''):
214             if counter == 1 or counter % 5 == 0:
215                 add_anchor(element, "f%d" % counter, link_text=counter)
216             counter += 1
217         elif 'paragraph' in element.get('class', ''):
218             add_anchor(element, "f%d" % counter, link_text=counter)
219             counter += 1
220
221
222 def raw_printable_text(element):
223     working = copy.deepcopy(element)
224     for e in working.findall('a'):
225         if e.get('class') in ('annotation', 'theme-begin'):
226             e.text = ''
227     return etree.tostring(working, method='text', encoding=unicode).strip()
228
229
230 def add_table_of_contents(root):
231     sections = []
232     counter = 1
233     for element in root.iterdescendants():
234         if element.tag in ('h2', 'h3'):
235             if any_ancestor(element, lambda e: e.get('id') in ('footnotes', 'nota_red') or e.get('class') in ('person-list',)):
236                 continue
237
238             element_text = raw_printable_text(element)
239             if element.tag == 'h3' and len(sections) and sections[-1][1] == 'h2':
240                 sections[-1][3].append((counter, element.tag, element_text, []))
241             else:
242                 sections.append((counter, element.tag, element_text, []))
243             add_anchor(element, "s%d" % counter, with_link=False)
244             counter += 1
245
246     toc = etree.Element('div')
247     toc.set('id', 'toc')
248     toc_header = etree.SubElement(toc, 'h2')
249     toc_header.text = u'Spis treści'
250     toc_list = etree.SubElement(toc, 'ol')
251
252     for n, section, text, subsections in sections:
253         section_element = etree.SubElement(toc_list, 'li')
254         add_anchor(section_element, "s%d" % n, with_target=False, link_text=text)
255
256         if len(subsections):
257             subsection_list = etree.SubElement(section_element, 'ol')
258             for n, subsection, text, _ in subsections:
259                 subsection_element = etree.SubElement(subsection_list, 'li')
260                 add_anchor(subsection_element, "s%d" % n, with_target=False, link_text=text)
261
262     root.insert(0, toc)
263
264     
265 def add_table_of_themes(root):
266     try:
267         from sortify import sortify
268     except ImportError:
269         sortify = lambda x: x
270
271     book_themes = {}
272     for fragment in root.findall('.//a[@class="theme-begin"]'):
273         if not fragment.text:
274             continue
275         theme_names = [s.strip() for s in fragment.text.split(',')]
276         for theme_name in theme_names:
277             book_themes.setdefault(theme_name, []).append(fragment.get('name'))
278     book_themes = book_themes.items()
279     book_themes.sort(key=lambda s: sortify(s[0]))
280     themes_div = etree.Element('div', id="themes")
281     themes_ol = etree.SubElement(themes_div, 'ol')
282     for theme_name, fragments in book_themes:
283         themes_li = etree.SubElement(themes_ol, 'li')
284         themes_li.text = "%s: " % theme_name
285         for i, fragment in enumerate(fragments):
286             item = etree.SubElement(themes_li, 'a', href="#%s" % fragment)
287             item.text = str(i + 1)
288             item.tail = ' '
289     root.insert(0, themes_div)
290
291
292
293 def extract_annotations(html_path):
294     """For each annotation, yields a tuple: anchor, text, html."""
295     parser = etree.HTMLParser(encoding='utf-8')
296     tree = etree.parse(html_path, parser)
297     footnotes = tree.find('//*[@id="footnotes"]')
298     if footnotes is not None:
299         for footnote in footnotes.findall('div'):
300             anchor = footnote.find('a[@name]').get('name')
301             del footnote[:2]
302             text_str = etree.tostring(footnote, method='text', encoding='utf-8').strip()
303             html_str = etree.tostring(footnote, method='html', encoding='utf-8')
304             yield anchor, text_str, html_str
305