diff --git a/docs/changelog.rst b/docs/changelog.rst index 7c76a38..adba2b4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog Unreleased ---------- +* **Enhancement**: Add the ability to configure an :ref:`simplepdf_html_hook` to modify the HTML before PDF generation. * **Bugfix** [#134] Improve support for external theme packages by using a ``get_scss_sources_path()`` convention. - If needed, theme warnings can be suppressed via ``suppress_warnings = ["simplepdf.theme"]``. diff --git a/docs/configuration.rst b/docs/configuration.rst index 4d81636..3a8e35b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -218,3 +218,62 @@ To reduce output noise the output can be filtered by a list of regular expressio ``simplepdf_weasyprint_filter = ["WARNING: Ignored"]`` To suppress all output, the quiet flag `-q` should be used. + +simplepdf_html_hook +------------------- +.. versionadded:: 1.7 + +Path to a Python script that will be called to manipulate the HTML before PDF generation. +The script must define a function named ``html_hook``. +This allows custom transformations using BeautifulSoup. + +.. note:: Only one hook script is supported per build: ``simplepdf_html_hook`` must be a single path. + Combine logic inside one script if you need multiple steps. + +**Format:** ``"path/to/script.py"`` + +The path can be absolute or relative to the ``conf.py`` directory. + +**Example conf.py:** + +.. code-block:: python + + simplepdf_html_hook = "./hooks/pdf_hook.py" + +**Example hook script (hooks/pdf_hook.py):** + +.. code-block:: python + + from bs4 import BeautifulSoup + + def html_hook(soup, app): + """ + Customize HTML before PDF generation. + + Args: + soup: BeautifulSoup object with parsed HTML + app: Sphinx application instance + Returns: + Modified BeautifulSoup object + """ + # Example: Remove navigation elements + for nav in soup.find_all("nav"): + nav.decompose() + + # Example: Add watermark + watermark = soup.new_tag("div", attrs={"class": "watermark"}) + watermark.string = "DRAFT" + body = soup.find("body") + if body: + body.insert(0, watermark) + + return soup + +**Function signature:** + +The ``html_hook`` function must accept two arguments: + +:soup: A ``BeautifulSoup`` object containing the parsed HTML +:app: The Sphinx application instance (provides access to ``config``, ``srcdir``, ``outdir``, etc.) + +The function must return a ``BeautifulSoup`` object. diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 74283c5..4452d6f 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -1,4 +1,5 @@ from collections import Counter +from collections.abc import Callable import importlib import os import re @@ -6,11 +7,12 @@ from typing import Any from bs4 import BeautifulSoup +from bs4.element import Tag import sass from sphinx import __version__ from sphinx.application import Sphinx from sphinx.builders.singlehtml import SingleFileHTMLBuilder -from sphinx.errors import ExtensionError +from sphinx.errors import ConfigError, ExtensionError from sphinx.util import logging import weasyprint @@ -28,7 +30,7 @@ class SimplePdfBuilder(SingleFileHTMLBuilder): default_translator_class = SimplepdfTranslator - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if self.app.config.simplepdf_theme is not None: logger.info(f"Setting theme to {self.app.config.simplepdf_theme}") @@ -67,6 +69,8 @@ def __init__(self, *args, **kwargs): }, ) + self._html_hook_func: Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | None = self._load_html_hook() + def get_config_var(self, name, default): """ Gets a config variables for scss out of the Sphinx configuration. @@ -162,7 +166,10 @@ def finish(self) -> None: with open(index_path, encoding="utf-8") as index_file: index_html = "".join(index_file.readlines()) - new_index_html = self._toctree_fix(index_html) + soup = BeautifulSoup(index_html, "html.parser") + soup = self._toctree_fix(soup) + soup = self._execute_html_hook(soup) + new_index_html = str(soup) with open(index_path, "w", encoding="utf-8") as index_file: index_file.writelines(new_index_html) @@ -218,6 +225,84 @@ def finish(self) -> None: logger.warning(f"weasyprint failed after {retries} retries") raise RuntimeError(f"maximum number of retries {retries} failed in weasyprint") + def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | None: + """Load the HTML hook function from the configured path. + + The script must define a function named ``html_hook``. + + Returns: + The hook function if configured, None otherwise. + + Raises: + ConfigError: If the hook configuration is invalid. + """ + hook_path = self.config["simplepdf_html_hook"] + if hook_path is None: + return None + + script_path = hook_path + + # Resolve path relative to conf.py directory + if not os.path.isabs(script_path): + script_path = os.path.join(self.app.confdir, script_path) + + # Check if file exists + if not os.path.isfile(script_path): + raise ConfigError(f"simplepdf_html_hook script not found: {script_path}") + + # Load the module. Use a unique virtual module name so parallel builds + # do not share one sys.modules entry for different hook paths. + mod_name = f"simplepdf_hook_{id(self)}" + spec = importlib.util.spec_from_file_location(mod_name, script_path) + if spec is None or spec.loader is None: + raise ConfigError(f"Cannot load module from: {script_path}") + + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except Exception as e: + raise ConfigError(f"Error loading simplepdf_html_hook script '{script_path}': {e}") from e + + hook_func = getattr(module, "html_hook", None) + if not callable(hook_func): + raise ConfigError( + f"Function 'html_hook' not found or not callable in simplepdf_html_hook script: {script_path}" + ) + + return hook_func + + def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: + """Execute the user-defined HTML hook if configured. + + Args: + soup: The BeautifulSoup object to pass to the hook. + + Returns: + The modified BeautifulSoup object. + + Raises: + ExtensionError: If the hook raises an exception, returns ``None``, or returns a value + that is not a ``BeautifulSoup`` instance. + """ + hook_func = self._html_hook_func + if hook_func is None: + return soup + + logger.info("Executing simplepdf_html_hook") + + try: + result = hook_func(soup, self.app) + except Exception as e: + raise ExtensionError(f"simplepdf_html_hook raised an exception: {e}") from e + + if result is None: + raise ExtensionError("simplepdf_html_hook returned None. The hook must return a BeautifulSoup object.") + + if not isinstance(result, BeautifulSoup): + raise ExtensionError(f"simplepdf_html_hook must return a BeautifulSoup object, got {type(result).__name__}") + + return result + """ attempts to fix cases where a document has multiple chapters that have the same name. @@ -243,13 +328,20 @@ def finish(self) -> None: """ - def _toctree_fix(self, html): + def _toctree_fix(self, soup: BeautifulSoup) -> BeautifulSoup: + """Fix toctree page numbering issues for documents with duplicate chapter names. + + Args: + soup: The BeautifulSoup object with parsed HTML. + + Returns: + The modified BeautifulSoup object. + """ logger.info("checking for potential toctree page numbering errors") - soup = BeautifulSoup(html, "html.parser") sidebar = soup.find("div", class_="sphinxsidebarwrapper") # sidebar contains the toctree - if sidebar is not None: + if isinstance(sidebar, Tag): toc_links = sidebar.find_all("a", class_="reference internal") # find max toctree lvl @@ -377,7 +469,7 @@ def _toctree_fix(self, html): logger.debug("DEBUG HTML START") logger.debug(soup.prettify(formatter="html")) logger.debug("DEBUG HTML END") - return str(soup) + return soup def setup(app: Sphinx) -> dict[str, Any]: @@ -392,6 +484,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_config_value("simplepdf_theme", "simplepdf_theme", "html", types=[str]) app.add_config_value("simplepdf_theme_options", {}, "html", types=[dict]) app.add_config_value("simplepdf_sidebars", {"**": ["localtoc.html"]}, "html", types=[dict]) + app.add_config_value("simplepdf_html_hook", None, "html", types=[str]) app.add_builder(SimplePdfBuilder) return { diff --git a/tests/doc_test/html_hook/conf.py b/tests/doc_test/html_hook/conf.py new file mode 100644 index 0000000..247e034 --- /dev/null +++ b/tests/doc_test/html_hook/conf.py @@ -0,0 +1,9 @@ +"""Test project for simplepdf_html_hook.""" + +project = "HtmlHookTest" +extensions = ["sphinx_simplepdf"] +master_doc = "index" +exclude_patterns = ["_build"] + +simplepdf_theme = "simplepdf_theme" +simplepdf_html_hook = "hooks/ok.py" diff --git a/tests/doc_test/html_hook/hooks/missing_def.py b/tests/doc_test/html_hook/hooks/missing_def.py new file mode 100644 index 0000000..ec37236 --- /dev/null +++ b/tests/doc_test/html_hook/hooks/missing_def.py @@ -0,0 +1,2 @@ +def not_html_hook() -> None: + pass diff --git a/tests/doc_test/html_hook/hooks/ok.py b/tests/doc_test/html_hook/hooks/ok.py new file mode 100644 index 0000000..8ad8744 --- /dev/null +++ b/tests/doc_test/html_hook/hooks/ok.py @@ -0,0 +1,5 @@ +from bs4 import BeautifulSoup + + +def html_hook(soup: BeautifulSoup, app: object) -> BeautifulSoup: + return soup diff --git a/tests/doc_test/html_hook/hooks/returns_none.py b/tests/doc_test/html_hook/hooks/returns_none.py new file mode 100644 index 0000000..60a431a --- /dev/null +++ b/tests/doc_test/html_hook/hooks/returns_none.py @@ -0,0 +1,5 @@ +from bs4 import BeautifulSoup + + +def html_hook(soup: BeautifulSoup, app: object) -> None: + return None diff --git a/tests/doc_test/html_hook/index.rst b/tests/doc_test/html_hook/index.rst new file mode 100644 index 0000000..e043a6a --- /dev/null +++ b/tests/doc_test/html_hook/index.rst @@ -0,0 +1,9 @@ +HTML hook test document +======================= + +Minimal content for SimplePDF hook tests. + +Section +------- + +Body. diff --git a/tests/test_html_hook.py b/tests/test_html_hook.py new file mode 100644 index 0000000..019b1aa --- /dev/null +++ b/tests/test_html_hook.py @@ -0,0 +1,37 @@ +"""Tests for simplepdf_html_hook configuration.""" + +import pytest +from sphinx.errors import ConfigError, ExtensionError + +from .utils import build_and_capture_stdout + + +def test_html_hook_valid_builds(sphinx_build, capsys): + """Configured hook script loads and build completes.""" + result = build_and_capture_stdout(sphinx_build, capsys, srcdir="html_hook") + assert result.pdf_exists() + assert not result.has_warnings("ERROR:") + + +def test_html_hook_missing_file_raises(sphinx_build): + with pytest.raises(ConfigError, match="not found"): + sphinx_build( + srcdir="html_hook", + confoverrides={"simplepdf_html_hook": "./does_not_exist_hook.py"}, + ).build() + + +def test_html_hook_missing_function_raises(sphinx_build): + with pytest.raises(ConfigError, match="html_hook"): + sphinx_build( + srcdir="html_hook", + confoverrides={"simplepdf_html_hook": "hooks/missing_def.py"}, + ).build() + + +def test_html_hook_returns_none_raises(sphinx_build): + with pytest.raises(ExtensionError, match="returned None"): + sphinx_build( + srcdir="html_hook", + confoverrides={"simplepdf_html_hook": "hooks/returns_none.py"}, + ).build()