Sphinx

# Source code for sphinx.builders.epub3

    """Build epub3 files.

    Originally derived from epub.py.
    """

    from __future__ import annotations

    import html
    import os
    import os.path
    import re
    import time
    from typing import TYPE_CHECKING, NamedTuple

    from sphinx import package_dir
    from sphinx.builders import _epub_base
    from sphinx.config import ENUM
    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 collections.abc import Set
        from typing import Any

        from sphinx.application import Sphinx
        from sphinx.config import Config
        from sphinx.util.typing import ExtensionMetadata

    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 = package_dir.joinpath('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] = [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(
                self.template_dir / 'nav.xhtml.jinja',
                self.outdir,
                context=self.navigation_doc_metadata(navlist),
                force=True,
            )

            # 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:
        """Convert 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

    def setup(app: Sphinx) -> ExtensionMetadata:
        app.add_builder(Epub3Builder)

        # config values
        app.add_config_value(
            'epub_basename',
            lambda self: make_filename(self.project),
            '',
            types=frozenset({str}),
        )
        app.add_config_value(
            'epub_version', 3.0, 'epub', types=frozenset({float})
        )  # experimental
        app.add_config_value('epub_theme', 'epub', 'epub', types=frozenset({str}))
        app.add_config_value('epub_theme_options', {}, 'epub', types=frozenset({dict}))
        app.add_config_value(
            'epub_title', lambda self: self.project, 'epub', types=frozenset({str})
        )
        app.add_config_value(
            'epub_author', lambda self: self.author, 'epub', types=frozenset({str})
        )
        app.add_config_value(
            'epub_language',
            lambda self: self.language or 'en',
            'epub',
            types=frozenset({str}),
        )
        app.add_config_value(
            'epub_publisher', lambda self: self.author, 'epub', types=frozenset({str})
        )
        app.add_config_value(
            'epub_copyright', lambda self: self.copyright, 'epub', types=frozenset({str})
        )
        app.add_config_value('epub_identifier', 'unknown', 'epub', types=frozenset({str}))
        app.add_config_value('epub_scheme', 'unknown', 'epub', types=frozenset({str}))
        app.add_config_value('epub_uid', 'unknown', 'env', types=frozenset({str}))
        app.add_config_value('epub_cover', (), 'env', types=frozenset({list, tuple}))
        app.add_config_value('epub_guide', (), 'env', types=frozenset({list, tuple}))
        app.add_config_value('epub_pre_files', [], 'env', types=frozenset({list, tuple}))
        app.add_config_value('epub_post_files', [], 'env', types=frozenset({list, tuple}))
        app.add_config_value(
            'epub_css_files',
            lambda config: config.html_css_files,
            'epub',
            types=frozenset({list, tuple}),
        )
        app.add_config_value(
            'epub_exclude_files', [], 'env', types=frozenset({list, tuple})
        )
        app.add_config_value('epub_tocdepth', 3, 'env', types=frozenset({int}))
        app.add_config_value('epub_tocdup', True, 'env', types=frozenset({bool}))
        app.add_config_value('epub_tocscope', 'default', 'env', types=frozenset({str}))
        app.add_config_value('epub_fix_images', False, 'env', types=frozenset({bool}))
        app.add_config_value('epub_max_image_width', 0, 'env', types=frozenset({int}))
        app.add_config_value('epub_show_urls', 'inline', 'epub', types=frozenset({str}))
        app.add_config_value(
            'epub_use_index',
            lambda self: self.html_use_index,
            'epub',
            types=frozenset({bool}),
        )
        app.add_config_value('epub_description', 'unknown', 'epub', types=frozenset({str}))
        app.add_config_value('epub_contributor', 'unknown', 'epub', types=frozenset({str}))
        app.add_config_value(
            'epub_writing_mode', 'horizontal', 'epub', types=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,
        }