Skip to main content
`Ctrl`+`K`

#

# Source code for doxysphinx.resources

    # =====================================================================================
    #  C O P Y R I G H T
    # -------------------------------------------------------------------------------------
    #  Copyright (c) 2023 by Robert Bosch GmbH. All rights reserved.
    #
    #  Author(s):
    #  - Markus Braun, :em engineering methods AG (contracted by Robert Bosch GmbH)
    # =====================================================================================
    """
    The resources module contains classes will do resource provisioning and patching.

    Resources are stylesheets, images, javascripts etc. that contemplate the html files.
    """

    import hashlib
    import logging
    import pkgutil
    from pathlib import Path
    from typing import Any, Callable, List, Optional, Protocol, Union

    import sass

    from doxysphinx.sphinx import DirectoryMapper

    # noinspection PyMethodMayBeStatic,PyUnusedLocal
    from doxysphinx.utils.exceptions import ApplicationError
    from doxysphinx.utils.files import copy_if_different, multi_glob, stringify_paths

    class ResourceProvider(Protocol):
        """A resource provider copies/adapts necessary resources (images, stylesheets, etc.) to output."""

        def __init__(self, directory_mapper: DirectoryMapper):
            """
            Protocol constructor.

            :param directory_mapper: the directory mapper to use.
            """
            pass

        def provide_resources(self, resource_root: Path) -> List[Path]:
            """
            Provide necessary resources to sphinx output directory.

            Can also do postprocessing etc.

            :param resource_root: the root resource input directory (e.g. where the html
                files are located)
            :return: A list of resources (their target paths) that were copied/provided.
                Note that in case of some caching (copy if newer) mechanisms this might
                return only parts of the resources.
            """
            return []

        def cleanup_resources(self, resource_root: Path) -> List[Path]:
            """
            Clean up provided resources that were copied by :meth:`provide_resources`.

            :param resource_root: the root resource input directory (e.g. where the html
                files are located)
            :return: A list of resources (their target paths) that were cleaned up/removed.
            """
            return []

    class DoxygenResourceProvider:
        """
        Resource provider that will copy/adapt doxygen html resources to output.

        Resource are e.g. stylesheets, images, javascript files etc.
        """

        _logger = logging.getLogger(__name__)

        # constant-ish patterns
        _provisioning_glob_pattern = [
            "*.css",
            "*.js",
            "*.map",
            "*.md5",
            "*.svg",
            "*.png",
            "search/*.*",
        ]
        _cleanup_glob_pattern = _provisioning_glob_pattern + ["*.scss"]

        def __init__(self, directory_mapper: DirectoryMapper):
            """
            Create a doxygen resource provider.

            :param directory_mapper: a directory mapper to use.
            """
            self._dir_mapper = directory_mapper
            self._css_scoper = CssScoper(".doxygen-content")
            self._custom_styles = self._load_custom_styles()

        def _load_custom_styles(self) -> str:
            data: Union[bytes, None] = pkgutil.get_data(__name__, "resources/custom.scss")
            if data:
                return "\n" + data.decode("utf-8") + "\n"
            else:
                self._logger.critical("could not read custom styles out of package. Exiting.... sorry! :-(")
                exit()

        def provide_resources(self, resource_root: Path) -> List[Path]:
            """
            Copy doxygen html resource files (see GLOB_PATTERN below) to sphinx output.

            The content in the raw html directives can then access these directly.

            :type resource_root: the root of the resources (= usually the same folder where
                the html file are located).
            """
            target = self._dir_mapper.map(resource_root)
            target.mkdir(parents=True, exist_ok=True)

            # copy file that are different in target, except for css files that we post process later (the
            # difference check would fail for them anyway)
            doxygen_css = resource_root / "doxygen.css"
            doxygen_awesome_css = resource_root / "doxygen-awesome.css"
            css_files_for_postprocessing = [doxygen_css, doxygen_awesome_css]
            copied_files = copy_if_different(
                resource_root, target, *self._provisioning_glob_pattern, ignore_files=css_files_for_postprocessing
            )

            self._logger.debug(f"copied files:\n{stringify_paths(copied_files)}")

            # postprocessing (patching + sass)
            # ... for doxygen.css if existing (a custom HTML_STYLESHEET disables it)
            if doxygen_css.exists():
                written_doxygen_css = self._css_scoper.scope(
                    stylesheet=doxygen_css,
                    target=target / doxygen_css.name,
                    additional_css_rules=self._custom_styles,
                    content_patch_callback=lambda s: str.replace(s, "code.JavaDocCode\n", "code.JavaDocCode {\n"),
                )
                if written_doxygen_css:
                    copied_files.append(written_doxygen_css)
            # ... for doxygen_awesome.css if existing
            if doxygen_awesome_css.exists():
                written_doxygen_awesome_css = self._css_scoper.scope(
                    stylesheet=doxygen_awesome_css,
                    target=target / doxygen_awesome_css.name,
                    content_patch_callback=lambda s: str.replace(s, "invert()", '#{"invert()"}'),
                )
                if written_doxygen_awesome_css:
                    copied_files.append(written_doxygen_awesome_css)

            return copied_files

        def cleanup_resources(self, resource_root: Path) -> List[Path]:
            """Clean up any provisioned resources that were copied to sphinx output."""
            target = self._dir_mapper.map(resource_root)
            target.mkdir(parents=True, exist_ok=True)

            cleanup_sources = multi_glob(resource_root, *self._cleanup_glob_pattern)
            files_deleted: List[Path] = []
            for source_file in cleanup_sources:
                target_file = target / source_file.relative_to(resource_root)
                if target_file.exists():
                    target_file.unlink()
                    self._logger.debug(f"deleted {target_file}")
                    files_deleted.append(target_file)

            return files_deleted

    class CssScoper:
        """Scopes css-stylesheets to a special selector.

        This is done with the help of libsass (as the sass-syntax extends css with nesting).

        Our original problem was that the doxygen stylesheet and the sphinx theme stylesheets collide in some
        ways (e.g. global styles like heading-styles etc...). We therefore needed to have a mechanism to apply
        doxygen stylesheets only to doxygen content (not to the outer sphinx theme shell). We do this via sass,
        because sass is css compatible but adds some nice features to it. You can for example nest styles.
        We use that here to define an outer class and nest the whole doxygen stylesheet below it in a temporary
        sass stylesheet which then gets compiled back to css. With this
        we kill 2 birds with one stone:
        * all doxygen rules are now scoped so they are not applied to the sphinx bits shell anymore....
        * all doxygen rules now are more specialized than any of the outer sphinx style rules (they will win in browser).

        In the end that means that sphinx styles are applied to sphinx bits and doxygen styles are applied to
        doxygen bits. We still need to fix some minor issues with a custom stylesheet (which we also apply here).
        """

        _logger = logging.getLogger(__name__)

        def __init__(self, css_selector: str):
            """
            Create a new CssScoper.

            :param css_selector: The selector where the stylesheets should be scoped under.
            """
            self._selector = css_selector

        def scope(
            self,
            stylesheet: Path,
            target: Path,
            additional_css_rules: Optional[str] = None,
            content_patch_callback: Optional[Callable[[str], str]] = None,
        ) -> Optional[Path]:
            """Scope a stylesheet to given selector.

            The process is as follows: The original stylesheet is read, processed, hashed and compiled to the
            target. If a target already exists and the hash is identical nothing is compiled and written.

            :param stylesheet: The path to a stylesheet to scope.
            :param: target: The path to a stylesheet where the results are written to.
            :param additional_css_rules: Additional css rules to inject.
            :param scss_patch_callback: A callback that will be called on the original file.
                   Note: we had a bug in doxygen.css and a sass compatibility fix for doxygen-awesome that made
                   this mechanism necessary. With one of the recent doxygen versions the doxygen.css bug was fixed
                   however we still keep it here some time.
            :return: The path to the written stylesheet (should be identical to stylesheet).
            """
            if stylesheet == target:
                raise ApplicationError(f"source ({stylesheet}) and target ({target}) stylesheets cannot be identical.")

            # load stylesheet and apply patches
            css_content = stylesheet.read_text(encoding="UTF-8")

            if content_patch_callback:
                css_content = content_patch_callback(css_content)

            # create .scss (sass) content scoped to the selector
            content = f"{self._selector} {{\n{css_content}\n}}\n"  # here we scope the content to a given css selector

            if additional_css_rules:
                content += additional_css_rules

            new_hash_digest = hashlib.blake2b(content.encode("utf-8")).hexdigest()

            old_hash_digest = self._read_hash_digest(target)
            if new_hash_digest == old_hash_digest:
                return None

            # add hash digest to content
            content = (
                f"/* {new_hash_digest} <- doxysphinx hash digest for the original input css that leads"
                f"to the css below */\n{content}"
            )

            # compile the scss to a css
            compiled_css: Any = sass.compile(
                string=content,
                output_style="expanded",
                indented=False,
                include_paths=[str(stylesheet.parent)],
            )

            # the sass compiler does also scope the html element (where typically css variables are
            # stored). We need to remove that scoping again because it will only work if it's in global scope.
            compiled_css = compiled_css.replace(f"{self._selector} html {{", "html {")

            # write stylesheet
            target.write_text(compiled_css, encoding="UTF-8")

            self._logger.debug(
                f"scoped original stylesheet '{stylesheet}' to selector '{self._selector}' in target '{target}'."
            )
            return target

        @staticmethod
        def _read_hash_digest(file: Path) -> str:
            if not file.exists():
                return ""

            digest_line: str
            with open(file, mode="r", encoding="utf") as f:
                digest_line = f.readline()
                # sometimes sass renders an @charset css directive which has to be on the first line (by css spec)
                # so in that case our hash digest is on the second line and we need to adapt to that...
                if digest_line.startswith("@charset"):
                    digest_line = f.readline()

            if not digest_line.startswith("/* "):
                return ""

            digest = digest_line[3:].split(" <- ")[0]
            return digest

        @staticmethod
        def _move(original: Path, new: Path):
            if new.exists():
                new.unlink()
            original.rename(new)