converters interface changed: WLDocument in, OutputFile out
[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_contents(result.getroot())
61
62             return OutputFile.from_string(etree.tostring(result, method='html',
63                 xml_declaration=False, pretty_print=True, encoding='utf-8'))
64         else:
65             return None
66     except KeyError:
67         raise ValueError("'%s' is not a valid stylesheet.")
68     except (XMLSyntaxError, XSLTApplyError), e:
69         raise ParseError(e)
70
71 class Fragment(object):
72     def __init__(self, id, themes):
73         super(Fragment, self).__init__()
74         self.id = id
75         self.themes = themes
76         self.events = []
77
78     def append(self, event, element):
79         self.events.append((event, element))
80
81     def closed_events(self):
82         stack = []
83         for event, element in self.events:
84             if event == 'start':
85                 stack.append(('end', element))
86             elif event == 'end':
87                 try:
88                     stack.pop()
89                 except IndexError:
90                     print 'CLOSED NON-OPEN TAG:', element
91
92         stack.reverse()
93         return self.events + stack
94
95     def to_string(self):
96         result = []
97         for event, element in self.closed_events():
98             if event == 'start':
99                 result.append(u'<%s %s>' % (element.tag, ' '.join('%s="%s"' % (k, v) for k, v in element.attrib.items())))
100                 if element.text:
101                     result.append(element.text)
102             elif event == 'end':
103                 result.append(u'</%s>' % element.tag)
104                 if element.tail:
105                     result.append(element.tail)
106             else:
107                 result.append(element)
108
109         return ''.join(result)
110
111     def __unicode__(self):
112         return self.to_string()
113
114
115 def extract_fragments(input_filename):
116     """Extracts theme fragments from input_filename."""
117     open_fragments = {}
118     closed_fragments = {}
119
120     # iterparse would die on a HTML document
121     parser = etree.HTMLParser(encoding='utf-8')
122     buf = cStringIO.StringIO()
123     buf.write(etree.tostring(etree.parse(input_filename, parser).getroot()[0][0], encoding='utf-8'))
124     buf.seek(0)
125
126     for event, element in etree.iterparse(buf, events=('start', 'end')):
127         # Process begin and end elements
128         if element.get('class', '') in ('theme-begin', 'theme-end'):
129             if not event == 'end': continue # Process elements only once, on end event
130
131             # Open new fragment
132             if element.get('class', '') == 'theme-begin':
133                 fragment = Fragment(id=element.get('fid'), themes=element.text)
134
135                 # Append parents
136                 if element.getparent().get('id', None) != 'book-text':
137                     parents = [element.getparent()]
138                     while parents[-1].getparent().get('id', None) != 'book-text':
139                         parents.append(parents[-1].getparent())
140
141                     parents.reverse()
142                     for parent in parents:
143                         fragment.append('start', parent)
144
145                 open_fragments[fragment.id] = fragment
146
147             # Close existing fragment
148             else:
149                 try:
150                     fragment = open_fragments[element.get('fid')]
151                 except KeyError:
152                     print '%s:closed not open fragment #%s' % (input_filename, element.get('fid'))
153                 else:
154                     closed_fragments[fragment.id] = fragment
155                     del open_fragments[fragment.id]
156
157             # Append element tail to lost_text (we don't want to lose any text)
158             if element.tail:
159                 for fragment_id in open_fragments:
160                     open_fragments[fragment_id].append('text', element.tail)
161
162
163         # Process all elements except begin and end
164         else:
165             # Omit annotation tags
166             if len(element.get('name', '')) or element.get('class', '') == 'annotation':
167                 if event == 'end' and element.tail:
168                     for fragment_id in open_fragments:
169                         open_fragments[fragment_id].append('text', element.tail)
170             else:
171                 for fragment_id in open_fragments:
172                     open_fragments[fragment_id].append(event, copy.copy(element))
173
174     return closed_fragments, open_fragments
175
176
177 def add_anchor(element, prefix, with_link=True, with_target=True, link_text=None):
178     if with_link:
179         if link_text is None:
180             link_text = prefix
181         anchor = etree.Element('a', href='#%s' % prefix)
182         anchor.set('class', 'anchor')
183         anchor.text = unicode(link_text)
184         if element.text:
185             anchor.tail = element.text
186             element.text = u''
187         element.insert(0, anchor)
188
189     if with_target:
190         anchor_target = etree.Element('a', name='%s' % prefix)
191         anchor_target.set('class', 'target')
192         anchor_target.text = u' '
193         if element.text:
194             anchor_target.tail = element.text
195             element.text = u''
196         element.insert(0, anchor_target)
197
198
199 def any_ancestor(element, test):
200     for ancestor in element.iterancestors():
201         if test(ancestor):
202             return True
203     return False
204
205
206 def add_anchors(root):
207     counter = 1
208     for element in root.iterdescendants():
209         if any_ancestor(element, lambda e: e.get('class') in ('note', 'motto', 'motto_podpis', 'dedication')
210         or e.get('id') == 'nota_red'
211         or e.tag == 'blockquote'):
212             continue
213
214         if element.tag == 'p' and 'verse' in element.get('class', ''):
215             if counter == 1 or counter % 5 == 0:
216                 add_anchor(element, "f%d" % counter, link_text=counter)
217             counter += 1
218         elif 'paragraph' in element.get('class', ''):
219             add_anchor(element, "f%d" % counter, link_text=counter)
220             counter += 1
221
222
223 def add_table_of_contents(root):
224     sections = []
225     counter = 1
226     for element in root.iterdescendants():
227         if element.tag in ('h2', 'h3'):
228             if any_ancestor(element, lambda e: e.get('id') in ('footnotes',) or e.get('class') in ('person-list',)):
229                 continue
230
231             if element.tag == 'h3' and len(sections) and sections[-1][1] == 'h2':
232                 sections[-1][3].append((counter, element.tag, ''.join(element.xpath('text()')), []))
233             else:
234                 sections.append((counter, element.tag, ''.join(element.xpath('text()')), []))
235             add_anchor(element, "s%d" % counter, with_link=False)
236             counter += 1
237
238     toc = etree.Element('div')
239     toc.set('id', 'toc')
240     toc_header = etree.SubElement(toc, 'h2')
241     toc_header.text = u'Spis treści'
242     toc_list = etree.SubElement(toc, 'ol')
243
244     for n, section, text, subsections in sections:
245         section_element = etree.SubElement(toc_list, 'li')
246         add_anchor(section_element, "s%d" % n, with_target=False, link_text=text)
247
248         if len(subsections):
249             subsection_list = etree.SubElement(section_element, 'ol')
250             for n, subsection, text, _ in subsections:
251                 subsection_element = etree.SubElement(subsection_list, 'li')
252                 add_anchor(subsection_element, "s%d" % n, with_target=False, link_text=text)
253
254     root.insert(0, toc)
255
256
257 def extract_annotations(html_path):
258     """For each annotation, yields a tuple: anchor, text, html."""
259     parser = etree.HTMLParser(encoding='utf-8')
260     tree = etree.parse(html_path, parser)
261     footnotes = tree.find('//*[@id="footnotes"]')
262     if footnotes is not None:
263         for footnote in footnotes.findall('div'):
264             anchor = footnote.find('a[@name]').get('name')
265             del footnote[:2]
266             text_str = etree.tostring(footnote, method='text', encoding='utf-8').strip()
267             html_str = etree.tostring(footnote, method='html', encoding='utf-8')
268             yield anchor, text_str, html_str
269