diff options
Diffstat (limited to '')
-rw-r--r-- | sphinx/builders/epub3.py | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/sphinx/builders/epub3.py b/sphinx/builders/epub3.py new file mode 100644 index 0000000..40d3ce7 --- /dev/null +++ b/sphinx/builders/epub3.py @@ -0,0 +1,301 @@ +"""Build epub3 files. + +Originally derived from epub.py. +""" + +from __future__ import annotations + +import html +import os +import re +import time +from os import path +from typing import TYPE_CHECKING, Any, NamedTuple + +from sphinx import package_dir +from sphinx.builders import _epub_base +from sphinx.config import ENUM, Config +from sphinx.locale import __ +from sphinx.util import logging +from sphinx.util.fileutil import copy_asset_file +from sphinx.util.osutil import make_filename + +if TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + + +class NavPoint(NamedTuple): + text: str + refuri: str + children: list[NavPoint] + + +# writing modes +PAGE_PROGRESSION_DIRECTIONS = { + 'horizontal': 'ltr', + 'vertical': 'rtl', +} +IBOOK_SCROLL_AXIS = { + 'horizontal': 'vertical', + 'vertical': 'horizontal', +} +THEME_WRITING_MODES = { + 'vertical': 'vertical-rl', + 'horizontal': 'horizontal-tb', +} + +DOCTYPE = '''<!DOCTYPE html>''' + +HTML_TAG = ( + '<html xmlns="http://www.w3.org/1999/xhtml" ' + 'xmlns:epub="http://www.idpf.org/2007/ops">' +) + +# https://www.w3.org/TR/REC-xml/#NT-Name +_xml_name_start_char = ( + ':|[A-Z]|_|[a-z]|[\u00C0-\u00D6]' + '|[\u00D8-\u00F6]|[\u00F8-\u02FF]|[\u0370-\u037D]' + '|[\u037F-\u1FFF]|[\u200C-\u200D]|[\u2070-\u218F]' + '|[\u2C00-\u2FEF]|[\u3001-\uD7FF]|[\uF900-\uFDCF]' + '|[\uFDF0-\uFFFD]|[\U00010000-\U000EFFFF]' +) +_xml_name_char = ( + _xml_name_start_char + r'\-|\.' '|[0-9]|\u00B7|[\u0300-\u036F]|[\u203F-\u2040]' +) +_XML_NAME_PATTERN = re.compile(f'({_xml_name_start_char})({_xml_name_char})*') + + +class Epub3Builder(_epub_base.EpubBuilder): + """ + Builder that outputs epub3 files. + + It creates the metainfo files content.opf, nav.xhtml, toc.ncx, mimetype, + and META-INF/container.xml. Afterwards, all necessary files are zipped to + an epub file. + """ + name = 'epub' + epilog = __('The ePub file is in %(outdir)s.') + + supported_remote_images = False + template_dir = path.join(package_dir, 'templates', 'epub3') + doctype = DOCTYPE + html_tag = HTML_TAG + use_meta_charset = True + + # Finish by building the epub file + def handle_finish(self) -> None: + """Create the metainfo files and finally the epub.""" + self.get_toc() + self.build_mimetype() + self.build_container() + self.build_content() + self.build_navigation_doc() + self.build_toc() + self.build_epub() + + def content_metadata(self) -> dict[str, Any]: + """Create a dictionary with all metadata for the content.opf + file properly escaped. + """ + writing_mode = self.config.epub_writing_mode + + if (source_date_epoch := os.getenv('SOURCE_DATE_EPOCH')) is not None: + time_tuple = time.gmtime(int(source_date_epoch)) + else: + time_tuple = time.gmtime() + + metadata = super().content_metadata() + metadata['description'] = html.escape(self.config.epub_description) + metadata['contributor'] = html.escape(self.config.epub_contributor) + metadata['page_progression_direction'] = PAGE_PROGRESSION_DIRECTIONS.get(writing_mode) + metadata['ibook_scroll_axis'] = IBOOK_SCROLL_AXIS.get(writing_mode) + metadata['date'] = html.escape(time.strftime("%Y-%m-%dT%H:%M:%SZ", time_tuple)) + metadata['version'] = html.escape(self.config.version) + metadata['epub_version'] = self.config.epub_version + return metadata + + def prepare_writing(self, docnames: set[str]) -> None: + super().prepare_writing(docnames) + + writing_mode = self.config.epub_writing_mode + self.globalcontext['theme_writing_mode'] = THEME_WRITING_MODES.get(writing_mode) + self.globalcontext['html_tag'] = self.html_tag + self.globalcontext['use_meta_charset'] = self.use_meta_charset + self.globalcontext['skip_ua_compatible'] = True + + def build_navlist(self, navnodes: list[dict[str, Any]]) -> list[NavPoint]: + """Create the toc navigation structure. + + This method is almost same as build_navpoints method in epub.py. + This is because the logical navigation structure of epub3 is not + different from one of epub2. + + The difference from build_navpoints method is templates which are used + when generating navigation documents. + """ + navstack: list[NavPoint] = [] + navstack.append(NavPoint('', '', [])) + level = 0 + for node in navnodes: + if not node['text']: + continue + file = node['refuri'].split('#')[0] + if file in self.ignored_files: + continue + if node['level'] > self.config.epub_tocdepth: + continue + + navpoint = NavPoint(node['text'], node['refuri'], []) + if node['level'] == level: + navstack.pop() + navstack[-1].children.append(navpoint) + navstack.append(navpoint) + elif node['level'] == level + 1: + level += 1 + navstack[-1].children.append(navpoint) + navstack.append(navpoint) + elif node['level'] < level: + while node['level'] < len(navstack): + navstack.pop() + level = node['level'] + navstack[-1].children.append(navpoint) + navstack.append(navpoint) + else: + unreachable = 'Should never reach here. It might be a bug.' + raise RuntimeError(unreachable) + + return navstack[0].children + + def navigation_doc_metadata(self, navlist: list[NavPoint]) -> dict[str, Any]: + """Create a dictionary with all metadata for the nav.xhtml file + properly escaped. + """ + return { + 'lang': html.escape(self.config.epub_language), + 'toc_locale': html.escape(self.guide_titles['toc']), + 'navlist': navlist, + } + + def build_navigation_doc(self) -> None: + """Write the metainfo file nav.xhtml.""" + logger.info(__('writing nav.xhtml file...')) + + if self.config.epub_tocscope == 'default': + doctree = self.env.get_and_resolve_doctree( + self.config.root_doc, self, + prune_toctrees=False, includehidden=False) + refnodes = self.get_refnodes(doctree, []) + self.toc_add_files(refnodes) + else: + # 'includehidden' + refnodes = self.refnodes + navlist = self.build_navlist(refnodes) + copy_asset_file(path.join(self.template_dir, 'nav.xhtml_t'), self.outdir, + self.navigation_doc_metadata(navlist)) + + # Add nav.xhtml to epub file + if 'nav.xhtml' not in self.files: + self.files.append('nav.xhtml') + + +def validate_config_values(app: Sphinx) -> None: + if app.builder.name != 'epub': + return + + # <package> lang attribute, dc:language + if not app.config.epub_language: + logger.warning(__('conf value "epub_language" (or "language") ' + 'should not be empty for EPUB3')) + # <package> unique-identifier attribute + if not _XML_NAME_PATTERN.match(app.config.epub_uid): + logger.warning(__('conf value "epub_uid" should be XML NAME for EPUB3')) + # dc:title + if not app.config.epub_title: + logger.warning(__('conf value "epub_title" (or "html_title") ' + 'should not be empty for EPUB3')) + # dc:creator + if not app.config.epub_author: + logger.warning(__('conf value "epub_author" should not be empty for EPUB3')) + # dc:contributor + if not app.config.epub_contributor: + logger.warning(__('conf value "epub_contributor" should not be empty for EPUB3')) + # dc:description + if not app.config.epub_description: + logger.warning(__('conf value "epub_description" should not be empty for EPUB3')) + # dc:publisher + if not app.config.epub_publisher: + logger.warning(__('conf value "epub_publisher" should not be empty for EPUB3')) + # dc:rights + if not app.config.epub_copyright: + logger.warning(__('conf value "epub_copyright" (or "copyright")' + 'should not be empty for EPUB3')) + # dc:identifier + if not app.config.epub_identifier: + logger.warning(__('conf value "epub_identifier" should not be empty for EPUB3')) + # meta ibooks:version + if not app.config.version: + logger.warning(__('conf value "version" should not be empty for EPUB3')) + + +def convert_epub_css_files(app: Sphinx, config: Config) -> None: + """This converts string styled epub_css_files to tuple styled one.""" + epub_css_files: list[tuple[str, dict[str, Any]]] = [] + for entry in config.epub_css_files: + if isinstance(entry, str): + epub_css_files.append((entry, {})) + else: + try: + filename, attrs = entry + epub_css_files.append((filename, attrs)) + except Exception: + logger.warning(__('invalid css_file: %r, ignored'), entry) + continue + + config.epub_css_files = epub_css_files # type: ignore[attr-defined] + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_builder(Epub3Builder) + + # config values + app.add_config_value('epub_basename', lambda self: make_filename(self.project), False) + app.add_config_value('epub_version', 3.0, 'epub') # experimental + app.add_config_value('epub_theme', 'epub', 'epub') + app.add_config_value('epub_theme_options', {}, 'epub') + app.add_config_value('epub_title', lambda self: self.project, 'epub') + app.add_config_value('epub_author', lambda self: self.author, 'epub') + app.add_config_value('epub_language', lambda self: self.language or 'en', 'epub') + app.add_config_value('epub_publisher', lambda self: self.author, 'epub') + app.add_config_value('epub_copyright', lambda self: self.copyright, 'epub') + app.add_config_value('epub_identifier', 'unknown', 'epub') + app.add_config_value('epub_scheme', 'unknown', 'epub') + app.add_config_value('epub_uid', 'unknown', 'env') + app.add_config_value('epub_cover', (), 'env') + app.add_config_value('epub_guide', (), 'env') + app.add_config_value('epub_pre_files', [], 'env') + app.add_config_value('epub_post_files', [], 'env') + app.add_config_value('epub_css_files', lambda config: config.html_css_files, 'epub') + app.add_config_value('epub_exclude_files', [], 'env') + app.add_config_value('epub_tocdepth', 3, 'env') + app.add_config_value('epub_tocdup', True, 'env') + app.add_config_value('epub_tocscope', 'default', 'env') + app.add_config_value('epub_fix_images', False, 'env') + app.add_config_value('epub_max_image_width', 0, 'env') + app.add_config_value('epub_show_urls', 'inline', 'epub') + app.add_config_value('epub_use_index', lambda self: self.html_use_index, 'epub') + app.add_config_value('epub_description', 'unknown', 'epub') + app.add_config_value('epub_contributor', 'unknown', 'epub') + app.add_config_value('epub_writing_mode', 'horizontal', 'epub', + ENUM('horizontal', 'vertical')) + + # event handlers + app.connect('config-inited', convert_epub_css_files, priority=800) + app.connect('builder-inited', validate_config_values) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } |