summaryrefslogtreecommitdiffstats
path: root/sphinx/builders/epub3.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/builders/epub3.py')
-rw-r--r--sphinx/builders/epub3.py301
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,
+ }