[epub, mobi] Add early hyphenate support for powiesc, opowiadania and wywiad. TODO...
[librarian.git] / librarian / epub.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 from __future__ import with_statement
7
8 import os
9 import os.path
10 import re
11 import subprocess
12 from StringIO import StringIO
13 from copy import deepcopy
14 from lxml import etree
15 import zipfile
16 from tempfile import mkdtemp, NamedTemporaryFile
17 from shutil import rmtree
18
19 from librarian import RDFNS, WLNS, NCXNS, OPFNS, XHTMLNS, OutputFile
20 from librarian.cover import DefaultEbookCover
21
22 from librarian import functions, get_resource
23
24 from librarian.hyphenator import Hyphenator
25
26 functions.reg_person_name()
27 functions.reg_lang_code_3to2()
28
29 hyph = Hyphenator(get_resource('res/hyph-dictionaries/hyph_pl_PL.dic'))
30
31 def hyphenate_and_fix_conjunctions(source_tree):
32     """ hyphenate only powiesc, opowiadanie and wywiad tag"""
33     texts = etree.XPath('//*[self::powiesc|self::opowiadanie|self::wywiad]//text()')(source_tree)
34     for t in texts:
35         parent = t.getparent()
36         newt = ''
37         wlist = re.compile(r'\w+|[^\w]', re.UNICODE).findall(t)
38         for w in wlist:
39             newt += hyph.inserted(w, u'\u00AD')       
40         newt = re.sub(r'(?<=\s\w)\s+', u'\u00A0', newt)
41         if t.is_text:
42             parent.text = newt
43         elif t.is_tail:
44             parent.tail = newt
45
46 def inner_xml(node):
47     """ returns node's text and children as a string
48
49     >>> print inner_xml(etree.fromstring('<a>x<b>y</b>z</a>'))
50     x<b>y</b>z
51     """
52
53     nt = node.text if node.text is not None else ''
54     return ''.join([nt] + [etree.tostring(child) for child in node])
55
56 def set_inner_xml(node, text):
57     """ sets node's text and children from a string
58
59     >>> e = etree.fromstring('<a>b<b>x</b>x</a>')
60     >>> set_inner_xml(e, 'x<b>y</b>z')
61     >>> print etree.tostring(e)
62     <a>x<b>y</b>z</a>
63     """
64
65     p = etree.fromstring('<x>%s</x>' % text)
66     node.text = p.text
67     node[:] = p[:]
68
69
70 def node_name(node):
71     """ Find out a node's name
72
73     >>> print node_name(etree.fromstring('<a>X<b>Y</b>Z</a>'))
74     XYZ
75     """
76
77     tempnode = deepcopy(node)
78
79     for p in ('pe', 'pa', 'pt', 'pr', 'motyw'):
80         for e in tempnode.findall('.//%s' % p):
81             t = e.tail
82             e.clear()
83             e.tail = t
84     etree.strip_tags(tempnode, '*')
85     return tempnode.text
86
87
88 def xslt(xml, sheet):
89     if isinstance(xml, etree._Element):
90         xml = etree.ElementTree(xml)
91     with open(sheet) as xsltf:
92         return xml.xslt(etree.parse(xsltf))
93
94
95 def replace_characters(node):
96     def replace_chars(text):
97         if text is None:
98             return None
99         return text.replace(u"\ufeff", u"")\
100                    .replace("---", u"\u2014")\
101                    .replace("--", u"\u2013")\
102                    .replace(",,", u"\u201E")\
103                    .replace('"', u"\u201D")\
104                    .replace("'", u"\u2019")
105     if node.tag in ('uwaga', 'extra'):
106         t = node.tail
107         node.clear()
108         node.tail = t
109     node.text = replace_chars(node.text)
110     node.tail = replace_chars(node.tail)
111     for child in node:
112         replace_characters(child)
113
114
115 def find_annotations(annotations, source, part_no):
116     for child in source:
117         if child.tag in ('pe', 'pa', 'pt', 'pr'):
118             annotation = deepcopy(child)
119             number = str(len(annotations)+1)
120             annotation.set('number', number)
121             annotation.set('part', str(part_no))
122             annotation.tail = ''
123             annotations.append(annotation)
124             tail = child.tail
125             child.clear()
126             child.tail = tail
127             child.text = number
128         if child.tag not in ('extra', 'uwaga'):
129             find_annotations(annotations, child, part_no)
130
131
132 class Stanza(object):
133     """
134     Converts / verse endings into verse elements in a stanza.
135
136     Slashes may only occur directly in the stanza. Any slashes in subelements
137     will be ignored, and the subelements will be put inside verse elements.
138
139     >>> s = etree.fromstring("<strofa>a <b>c</b> <b>c</b>/\\nb<x>x/\\ny</x>c/ \\nd</strofa>")
140     >>> Stanza(s).versify()
141     >>> print etree.tostring(s)
142     <strofa><wers_normalny>a <b>c</b> <b>c</b></wers_normalny><wers_normalny>b<x>x/
143     y</x>c</wers_normalny><wers_normalny>d</wers_normalny></strofa>
144     
145     """
146     def __init__(self, stanza_elem):
147         self.stanza = stanza_elem
148         self.verses = []
149         self.open_verse = None
150
151     def versify(self):
152         self.push_text(self.stanza.text)
153         for elem in self.stanza:
154             self.push_elem(elem)
155             self.push_text(elem.tail)
156         tail = self.stanza.tail
157         self.stanza.clear()
158         self.stanza.tail = tail
159         self.stanza.extend(self.verses)
160
161     def open_normal_verse(self):
162         self.open_verse = self.stanza.makeelement("wers_normalny")
163         self.verses.append(self.open_verse)
164
165     def get_open_verse(self):
166         if self.open_verse is None:
167             self.open_normal_verse()
168         return self.open_verse
169
170     def push_text(self, text):
171         if not text:
172             return
173         for i, verse_text in enumerate(re.split(r"/\s*\n", text)):
174             if i:
175                 self.open_normal_verse()
176             verse = self.get_open_verse()
177             if len(verse):
178                 verse[-1].tail = (verse[-1].tail or "") + verse_text
179             else:
180                 verse.text = (verse.text or "") + verse_text
181
182     def push_elem(self, elem):
183         if elem.tag.startswith("wers"):
184             verse = deepcopy(elem)
185             verse.tail = None
186             self.verses.append(verse)
187             self.open_verse = verse
188         else:
189             appended = deepcopy(elem)
190             appended.tail = None
191             self.get_open_verse().append(appended)
192
193
194 def replace_by_verse(tree):
195     """ Find stanzas and create new verses in place of a '/' character """
196
197     stanzas = tree.findall('.//' + WLNS('strofa'))
198     for stanza in stanzas:
199         Stanza(stanza).versify()
200
201
202 def add_to_manifest(manifest, partno):
203     """ Adds a node to the manifest section in content.opf file """
204
205     partstr = 'part%d' % partno
206     e = manifest.makeelement(OPFNS('item'), attrib={
207                                  'id': partstr,
208                                  'href': partstr + '.html',
209                                  'media-type': 'application/xhtml+xml',
210                              })
211     manifest.append(e)
212
213
214 def add_to_spine(spine, partno):
215     """ Adds a node to the spine section in content.opf file """
216
217     e = spine.makeelement(OPFNS('itemref'), attrib={'idref': 'part%d' % partno});
218     spine.append(e)
219
220
221 class TOC(object):
222     def __init__(self, name=None, part_href=None):
223         self.children = []
224         self.name = name
225         self.part_href = part_href
226         self.sub_number = None
227
228     def add(self, name, part_href, level=0, is_part=True, index=None):
229         assert level == 0 or index is None
230         if level > 0 and self.children:
231             return self.children[-1].add(name, part_href, level-1, is_part)
232         else:
233             t = TOC(name)
234             t.part_href = part_href
235             if index is not None:
236                 self.children.insert(index, t)
237             else:
238                 self.children.append(t)
239             if not is_part:
240                 t.sub_number = len(self.children) + 1
241                 return t.sub_number
242
243     def append(self, toc):
244         self.children.append(toc)
245
246     def extend(self, toc):
247         self.children.extend(toc.children)
248
249     def depth(self):
250         if self.children:
251             return max((c.depth() for c in self.children)) + 1
252         else:
253             return 0
254
255     def href(self):
256         src = self.part_href
257         if self.sub_number is not None:
258             src += '#sub%d' % self.sub_number
259         return src
260
261     def write_to_xml(self, nav_map, counter=1):
262         for child in self.children:
263             nav_point = nav_map.makeelement(NCXNS('navPoint'))
264             nav_point.set('id', 'NavPoint-%d' % counter)
265             nav_point.set('playOrder', str(counter))
266
267             nav_label = nav_map.makeelement(NCXNS('navLabel'))
268             text = nav_map.makeelement(NCXNS('text'))
269             text.text = re.sub(r'\n', ' ', child.name)
270             nav_label.append(text)
271             nav_point.append(nav_label)
272
273             content = nav_map.makeelement(NCXNS('content'))
274             content.set('src', child.href())
275             nav_point.append(content)
276             nav_map.append(nav_point)
277             counter = child.write_to_xml(nav_point, counter + 1)
278         return counter
279
280     def html_part(self, depth=0):
281         texts = []
282         for child in self.children:
283             texts.append(
284                 "<div style='margin-left:%dem;'><a href='%s'>%s</a></div>" %
285                 (depth, child.href(), child.name))
286             texts.append(child.html_part(depth+1))
287         return "\n".join(texts)
288
289     def html(self):
290         with open(get_resource('epub/toc.html')) as f:
291             t = unicode(f.read(), 'utf-8')
292         return t % self.html_part()
293
294
295 def used_chars(element):
296     """ Lists characters used in an ETree Element """
297     chars = set((element.text or '') + (element.tail or ''))
298     for child in element:
299         chars = chars.union(used_chars(child))
300     return chars
301
302
303 def chop(main_text):
304     """ divide main content of the XML file into chunks """
305
306     # prepare a container for each chunk
307     part_xml = etree.Element('utwor')
308     etree.SubElement(part_xml, 'master')
309     main_xml_part = part_xml[0] # master
310
311     last_node_part = False
312     
313     # the below loop are workaround for a problem with epubs in drama ebooks without acts
314     is_scene = False
315     is_act = False
316     for one_part in main_text:
317         name = one_part.tag
318         if name == 'naglowek_scena':
319             is_scene = True
320         elif name == 'naglowek_akt':
321             is_act = True
322     
323     for one_part in main_text:
324         name = one_part.tag
325         if is_act is False and is_scene is True:
326             if name == 'naglowek_czesc':
327                 yield part_xml
328                 last_node_part = True
329                 main_xml_part[:] = [deepcopy(one_part)]
330             elif not last_node_part and name == "naglowek_scena":
331                 yield part_xml
332                 main_xml_part[:] = [deepcopy(one_part)]
333             else:
334                 main_xml_part.append(deepcopy(one_part))
335                 last_node_part = False
336         else:
337             if name == 'naglowek_czesc':
338                 yield part_xml
339                 last_node_part = True
340                 main_xml_part[:] = [deepcopy(one_part)]
341             elif not last_node_part and name in ("naglowek_rozdzial", "naglowek_akt", "srodtytul"):
342                 yield part_xml
343                 main_xml_part[:] = [deepcopy(one_part)]
344             else:
345                 main_xml_part.append(deepcopy(one_part))
346                 last_node_part = False            
347     yield part_xml
348
349
350 def transform_chunk(chunk_xml, chunk_no, annotations, empty=False, _empty_html_static=[]):
351     """ transforms one chunk, returns a HTML string, a TOC object and a set of used characters """
352
353     toc = TOC()
354     for element in chunk_xml[0]:
355         if element.tag == "naglowek_czesc":
356             toc.add(node_name(element), "part%d.html#book-text" % chunk_no)
357         elif element.tag in ("naglowek_rozdzial", "naglowek_akt", "srodtytul"):
358             toc.add(node_name(element), "part%d.html" % chunk_no)
359         elif element.tag in ('naglowek_podrozdzial', 'naglowek_scena'):
360             subnumber = toc.add(node_name(element), "part%d.html" % chunk_no, level=1, is_part=False)
361             element.set('sub', str(subnumber))
362     if empty:
363         if not _empty_html_static:
364             _empty_html_static.append(open(get_resource('epub/emptyChunk.html')).read())
365         chars = set()
366         output_html = _empty_html_static[0]
367     else:
368         find_annotations(annotations, chunk_xml, chunk_no)
369         replace_by_verse(chunk_xml)
370         html_tree = xslt(chunk_xml, get_resource('epub/xsltScheme.xsl'))
371         chars = used_chars(html_tree.getroot())
372         output_html = etree.tostring(html_tree, method="html", pretty_print=True)
373     return output_html, toc, chars
374
375
376 def transform(wldoc, verbose=False,
377               style=None, html_toc=False,
378               sample=None, cover=None, flags=None):
379     """ produces a EPUB file
380
381     sample=n: generate sample e-book (with at least n paragraphs)
382     cover: a cover.Cover factory or True for default
383     flags: less-advertising, without-fonts, working-copy, with-full-fonts
384     """
385
386     def transform_file(wldoc, chunk_counter=1, first=True, sample=None):
387         """ processes one input file and proceeds to its children """
388
389         replace_characters(wldoc.edoc.getroot())
390         hyphenate_and_fix_conjunctions(wldoc.edoc.getroot())
391         
392         # every input file will have a TOC entry,
393         # pointing to starting chunk
394         toc = TOC(wldoc.book_info.title, "part%d.html" % chunk_counter)
395         chars = set()
396         if first:
397             # write book title page
398             html_tree = xslt(wldoc.edoc, get_resource('epub/xsltTitle.xsl'))
399             chars = used_chars(html_tree.getroot())
400             zip.writestr('OPS/title.html',
401                  etree.tostring(html_tree, method="html", pretty_print=True))
402             # add a title page TOC entry
403             toc.add(u"Strona tytułowa", "title.html")
404         elif wldoc.book_info.parts:
405             # write title page for every parent
406             if sample is not None and sample <= 0:
407                 chars = set()
408                 html_string = open(get_resource('epub/emptyChunk.html')).read()
409             else:
410                 html_tree = xslt(wldoc.edoc, get_resource('epub/xsltChunkTitle.xsl'))
411                 chars = used_chars(html_tree.getroot())
412                 html_string = etree.tostring(html_tree, method="html", pretty_print=True)
413             zip.writestr('OPS/part%d.html' % chunk_counter, html_string)
414             add_to_manifest(manifest, chunk_counter)
415             add_to_spine(spine, chunk_counter)
416             chunk_counter += 1
417
418         if len(wldoc.edoc.getroot()) > 1:
419             # rdf before style master
420             main_text = wldoc.edoc.getroot()[1]
421         else:
422             # rdf in style master
423             main_text = wldoc.edoc.getroot()[0]
424             if main_text.tag == RDFNS('RDF'):
425                 main_text = None
426
427         if main_text is not None:
428             for chunk_xml in chop(main_text):
429                 empty = False
430                 if sample is not None:
431                     if sample <= 0:
432                         empty = True
433                     else:
434                         sample -= len(chunk_xml.xpath('//strofa|//akap|//akap_cd|//akap_dialog'))
435                 chunk_html, chunk_toc, chunk_chars = transform_chunk(chunk_xml, chunk_counter, annotations, empty)
436
437                 toc.extend(chunk_toc)
438                 chars = chars.union(chunk_chars)
439                 zip.writestr('OPS/part%d.html' % chunk_counter, chunk_html)
440                 add_to_manifest(manifest, chunk_counter)
441                 add_to_spine(spine, chunk_counter)
442                 chunk_counter += 1
443
444         for child in wldoc.parts():
445             child_toc, chunk_counter, chunk_chars, sample = transform_file(
446                 child, chunk_counter, first=False, sample=sample)
447             toc.append(child_toc)
448             chars = chars.union(chunk_chars)
449
450         return toc, chunk_counter, chars, sample
451
452
453     document = deepcopy(wldoc)
454     del wldoc
455
456     if flags:
457         for flag in flags:
458             document.edoc.getroot().set(flag, 'yes')
459
460     # add editors info
461     document.edoc.getroot().set('editors', u', '.join(sorted(
462         editor.readable() for editor in document.editors())))
463     if document.book_info.funders:
464         document.edoc.getroot().set('funders', u', '.join(
465             document.book_info.funders))
466     if document.book_info.thanks:
467         document.edoc.getroot().set('thanks', document.book_info.thanks)
468
469     opf = xslt(document.book_info.to_etree(), get_resource('epub/xsltContent.xsl'))
470     manifest = opf.find('.//' + OPFNS('manifest'))
471     guide = opf.find('.//' + OPFNS('guide'))
472     spine = opf.find('.//' + OPFNS('spine'))
473
474     output_file = NamedTemporaryFile(prefix='librarian', suffix='.epub', delete=False)
475     zip = zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED)
476
477     # write static elements
478     mime = zipfile.ZipInfo()
479     mime.filename = 'mimetype'
480     mime.compress_type = zipfile.ZIP_STORED
481     mime.extra = ''
482     zip.writestr(mime, 'application/epub+zip')
483     zip.writestr('META-INF/container.xml', '<?xml version="1.0" ?><container version="1.0" ' \
484                        'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">' \
485                        '<rootfiles><rootfile full-path="OPS/content.opf" ' \
486                        'media-type="application/oebps-package+xml" />' \
487                        '</rootfiles></container>')
488     zip.write(get_resource('res/wl-logo-small.png'), os.path.join('OPS', 'logo_wolnelektury.png'))
489     zip.write(get_resource('res/jedenprocent.png'), os.path.join('OPS', 'jedenprocent.png'))
490     if not style:
491         style = get_resource('epub/style.css')
492     zip.write(style, os.path.join('OPS', 'style.css'))
493
494     if cover:
495         if cover is True:
496             cover = DefaultEbookCover
497
498         cover_file = StringIO()
499         bound_cover = cover(document.book_info)
500         bound_cover.save(cover_file)
501         cover_name = 'cover.%s' % bound_cover.ext()
502         zip.writestr(os.path.join('OPS', cover_name), cover_file.getvalue())
503         del cover_file
504
505         cover_tree = etree.parse(get_resource('epub/cover.html'))
506         cover_tree.find('//' + XHTMLNS('img')).set('src', cover_name)
507         zip.writestr('OPS/cover.html', etree.tostring(
508                         cover_tree, method="html", pretty_print=True))
509
510         if bound_cover.uses_dc_cover:
511             if document.book_info.cover_by:
512                 document.edoc.getroot().set('data-cover-by', document.book_info.cover_by)
513             if document.book_info.cover_source:
514                 document.edoc.getroot().set('data-cover-source', document.book_info.cover_source)
515
516         manifest.append(etree.fromstring(
517             '<item id="cover" href="cover.html" media-type="application/xhtml+xml" />'))
518         manifest.append(etree.fromstring(
519             '<item id="cover-image" href="%s" media-type="%s" />' % (cover_name, bound_cover.mime_type())))
520         spine.insert(0, etree.fromstring('<itemref idref="cover"/>'))
521         opf.getroot()[0].append(etree.fromstring('<meta name="cover" content="cover-image"/>'))
522         guide.append(etree.fromstring('<reference href="cover.html" type="cover" title="Okładka"/>'))
523
524
525     annotations = etree.Element('annotations')
526
527     toc_file = etree.fromstring('<?xml version="1.0" encoding="utf-8"?><!DOCTYPE ncx PUBLIC ' \
528                                '"-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">' \
529                                '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" xml:lang="pl" ' \
530                                'version="2005-1"><head></head><docTitle></docTitle><navMap>' \
531                                '</navMap></ncx>')
532     nav_map = toc_file[-1]
533
534     if html_toc:
535         manifest.append(etree.fromstring(
536             '<item id="html_toc" href="toc.html" media-type="application/xhtml+xml" />'))
537         spine.append(etree.fromstring(
538             '<itemref idref="html_toc" />'))
539         guide.append(etree.fromstring('<reference href="toc.html" type="toc" title="Spis treści"/>'))
540
541     toc, chunk_counter, chars, sample = transform_file(document, sample=sample)
542
543     if len(toc.children) < 2:
544         toc.add(u"Początek utworu", "part1.html")
545
546     # Last modifications in container files and EPUB creation
547     if len(annotations) > 0:
548         toc.add("Przypisy", "annotations.html")
549         manifest.append(etree.fromstring(
550             '<item id="annotations" href="annotations.html" media-type="application/xhtml+xml" />'))
551         spine.append(etree.fromstring(
552             '<itemref idref="annotations" />'))
553         replace_by_verse(annotations)
554         html_tree = xslt(annotations, get_resource('epub/xsltAnnotations.xsl'))
555         chars = chars.union(used_chars(html_tree.getroot()))
556         zip.writestr('OPS/annotations.html', etree.tostring(
557                             html_tree, method="html", pretty_print=True))
558
559     toc.add("Wesprzyj Wolne Lektury", "support.html")
560     manifest.append(etree.fromstring(
561         '<item id="support" href="support.html" media-type="application/xhtml+xml" />'))
562     spine.append(etree.fromstring(
563         '<itemref idref="support" />'))
564     html_string = open(get_resource('epub/support.html')).read()
565     chars.update(used_chars(etree.fromstring(html_string)))
566     zip.writestr('OPS/support.html', html_string)
567
568     toc.add("Strona redakcyjna", "last.html")
569     manifest.append(etree.fromstring(
570         '<item id="last" href="last.html" media-type="application/xhtml+xml" />'))
571     spine.append(etree.fromstring(
572         '<itemref idref="last" />'))
573     html_tree = xslt(document.edoc, get_resource('epub/xsltLast.xsl'))
574     chars.update(used_chars(html_tree.getroot()))
575     zip.writestr('OPS/last.html', etree.tostring(
576                         html_tree, method="html", pretty_print=True))
577
578     if not flags or not 'without-fonts' in flags:
579         # strip fonts
580         tmpdir = mkdtemp('-librarian-epub')
581         try:
582             cwd = os.getcwd()
583         except OSError:
584             cwd = None
585
586         os.chdir(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'font-optimizer'))
587         for fname in 'DejaVuSerif.ttf', 'DejaVuSerif-Bold.ttf', 'DejaVuSerif-Italic.ttf', 'DejaVuSerif-BoldItalic.ttf':
588             if not flags or not 'with-full-fonts' in flags:
589                 optimizer_call = ['perl', 'subset.pl', '--chars', ''.join(chars).encode('utf-8'),
590                               get_resource('fonts/' + fname), os.path.join(tmpdir, fname)]              
591                 if verbose:
592                     print "Running font-optimizer"
593                     subprocess.check_call(optimizer_call)
594                 else:
595                     subprocess.check_call(optimizer_call, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
596                     zip.write(os.path.join(tmpdir, fname), os.path.join('OPS', fname))
597             else:
598                 zip.write(get_resource('fonts/' + fname), os.path.join('OPS', fname))
599             manifest.append(etree.fromstring(
600                 '<item id="%s" href="%s" media-type="application/x-font-truetype" />' % (fname, fname)))
601         rmtree(tmpdir)
602         if cwd is not None:
603             os.chdir(cwd)
604     zip.writestr('OPS/content.opf', etree.tostring(opf, pretty_print=True, xml_declaration = True, encoding='UTF-8'))
605     title = document.book_info.title
606     attributes = "dtb:uid", "dtb:depth", "dtb:totalPageCount", "dtb:maxPageNumber"
607     for st in attributes:
608         meta = toc_file.makeelement(NCXNS('meta'))
609         meta.set('name', st)
610         meta.set('content', '0')
611         toc_file[0].append(meta)
612     toc_file[0][0].set('content', str(document.book_info.url))
613     toc_file[0][1].set('content', str(toc.depth()))
614     set_inner_xml(toc_file[1], ''.join(('<text>', title, '</text>')))
615
616     # write TOC
617     if html_toc:
618         toc.add(u"Spis treści", "toc.html", index=1)
619         zip.writestr('OPS/toc.html', toc.html().encode('utf-8'))
620     toc.write_to_xml(nav_map)
621     zip.writestr('OPS/toc.ncx', etree.tostring(toc_file, pretty_print=True, xml_declaration = True, encoding='UTF-8'))
622     zip.close()
623
624     return OutputFile.from_filename(output_file.name)