From 30e14d8ab421580a8764c8c089a35b489848d60e Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Wed, 28 Jan 2026 11:59:48 -0800 Subject: [PATCH 01/10] Add hook to manipulate HTML --- docs/configuration.rst | 63 +++++++++++++ sphinx_simplepdf/builders/simplepdf.py | 125 ++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 233c40e..4afcfb0 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -191,3 +191,66 @@ 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 quite flag `-q` should be used. + +simplepdf_html_hook +------------------- +.. versionadded:: 1.7 + +Path to a Python script and function that will be called to manipulate the HTML before PDF generation. +This allows custom transformations using BeautifulSoup. + +**Format:** ``"path/to/script.py:function_name"`` + +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:customize_html" + +**Example hook script (hooks/pdf_hook.py):** + +.. code-block:: python + + from bs4 import BeautifulSoup + + def customize_html(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 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. + +**Error handling:** + +- If the script file is not found, a ``ConfigError`` is raised +- If the function is not found in the script, a ``ConfigError`` is raised +- If the hook returns ``None``, a warning is logged and the original HTML is used +- If the hook returns a non-BeautifulSoup type, an error is raised +- If the hook raises an exception, it is wrapped in an ``ExtensionError`` diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 509b486..fbc1669 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -1,8 +1,9 @@ +import importlib.util import os import re import subprocess from collections import Counter -from typing import Any +from typing import Any, Callable import sass import weasyprint @@ -10,6 +11,7 @@ from sphinx import __version__ from sphinx.application import Sphinx from sphinx.builders.singlehtml import SingleFileHTMLBuilder +from sphinx.errors import ConfigError, ExtensionError from sphinx.util import logging from sphinx_simplepdf.builders.debug import DebugPython @@ -114,7 +116,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) + 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) @@ -169,6 +174,108 @@ def finish(self) -> None: if (n == retries - 1) and not success: 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. + + 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 + + # Parse the path:function_name format + if ":" not in hook_path: + raise ConfigError( + f"simplepdf_html_hook must be in format 'path/to/script.py:function_name', " + f"got '{hook_path}'" + ) + + script_path, function_name = hook_path.rsplit(":", 1) + + # 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 + spec = importlib.util.spec_from_file_location("simplepdf_hook", script_path) + if spec is None or spec.loader is None: + raise ConfigError( + f"Failed to load simplepdf_html_hook script: {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 + + # Get the function + if not hasattr(module, function_name): + raise ConfigError( + f"Function '{function_name}' not found in simplepdf_html_hook script: {script_path}" + ) + + hook_func = getattr(module, function_name) + + if not callable(hook_func): + raise ConfigError( + f"simplepdf_html_hook '{function_name}' in '{script_path}' is not callable" + ) + + 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 or returns an invalid type. + """ + hook_func = self._load_html_hook() + 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: + logger.warning( + "simplepdf_html_hook returned None, using original HTML. " + "The hook should return a BeautifulSoup object." + ) + return soup + + if not isinstance(result, BeautifulSoup): + raise ExtensionError( + f"simplepdf_html_hook must return a BeautifulSoup object, " + f"got {type(result).__name__}" + ) + + return result + """ attempts to fix cases where a document has multiple chapters that have the same name. @@ -194,9 +301,16 @@ def finish(self) -> None: """ - def _toctree_fix(self, html): + def _toctree_fix_soup(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. + """ print("checking for potential toctree page numbering errors") - soup = BeautifulSoup(html, "html.parser") sidebar = soup.find("div", class_="sphinxsidebarwrapper") # sidebar contains the toctree @@ -315,7 +429,7 @@ def _toctree_fix(self, html): heading.attrs["class"] = class_attr logger.debug(soup.prettify(formatter="html")) - return str(soup) + return soup def setup(app: Sphinx) -> dict[str, Any]: @@ -330,6 +444,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 { From 0ff54242b796be9fcde10cc8c9071934fce9e2b5 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Tue, 17 Mar 2026 17:57:10 -0700 Subject: [PATCH 02/10] use a set function name --- docs/changelog.rst | 5 +++++ docs/configuration.rst | 11 ++++----- pyproject.toml | 2 +- sphinx_simplepdf/builders/simplepdf.py | 31 ++++++-------------------- 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 97268e8..bb7ea79 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,11 @@ Changelog ========= +Release 1.8 +----------- + +* **Enhancement**: Add the ability to configure an :ref:`simplepdf_html_hook` to modify the HTML before PDF generation. + Release 1.7 ----------- :released: 02.12.2025 diff --git a/docs/configuration.rst b/docs/configuration.rst index 4afcfb0..8558c1c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -196,10 +196,11 @@ simplepdf_html_hook ------------------- .. versionadded:: 1.7 -Path to a Python script and function that will be called to manipulate the HTML before PDF generation. +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. -**Format:** ``"path/to/script.py:function_name"`` +**Format:** ``"path/to/script.py"`` The path can be absolute or relative to the ``conf.py`` directory. @@ -207,7 +208,7 @@ The path can be absolute or relative to the ``conf.py`` directory. .. code-block:: python - simplepdf_html_hook = "./hooks/pdf_hook.py:customize_html" + simplepdf_html_hook = "./hooks/pdf_hook.py" **Example hook script (hooks/pdf_hook.py):** @@ -215,7 +216,7 @@ The path can be absolute or relative to the ``conf.py`` directory. from bs4 import BeautifulSoup - def customize_html(soup, app): + def html_hook(soup, app): """ Customize HTML before PDF generation. @@ -240,7 +241,7 @@ The path can be absolute or relative to the ``conf.py`` directory. **Function signature:** -The hook function must accept two arguments: +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.) diff --git a/pyproject.toml b/pyproject.toml index e242d24..0735b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "sphinx-simplepdf" -version = "1.7.0" +version = "1.8.0" description = "An easy to use PDF Builder for Sphinx with a modern PDF-Theme." readme = "README.rst" license = "MIT" diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index fbc1669..cbb2073 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -117,7 +117,7 @@ def finish(self) -> None: index_html = "".join(index_file.readlines()) soup = BeautifulSoup(index_html, "html.parser") - soup = self._toctree_fix_soup(soup) + soup = self._toctree_fix(soup) soup = self._execute_html_hook(soup) new_index_html = str(soup) @@ -177,6 +177,8 @@ def finish(self) -> None: 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. @@ -187,14 +189,7 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | if hook_path is None: return None - # Parse the path:function_name format - if ":" not in hook_path: - raise ConfigError( - f"simplepdf_html_hook must be in format 'path/to/script.py:function_name', " - f"got '{hook_path}'" - ) - - script_path, function_name = hook_path.rsplit(":", 1) + script_path = hook_path # Resolve path relative to conf.py directory if not os.path.isabs(script_path): @@ -208,11 +203,6 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | # Load the module spec = importlib.util.spec_from_file_location("simplepdf_hook", script_path) - if spec is None or spec.loader is None: - raise ConfigError( - f"Failed to load simplepdf_html_hook script: {script_path}" - ) - module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) @@ -221,17 +211,10 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | f"Error loading simplepdf_html_hook script '{script_path}': {e}" ) from e - # Get the function - if not hasattr(module, function_name): - raise ConfigError( - f"Function '{function_name}' not found in simplepdf_html_hook script: {script_path}" - ) - - hook_func = getattr(module, function_name) - + hook_func = getattr(module, "html_hook", None) if not callable(hook_func): raise ConfigError( - f"simplepdf_html_hook '{function_name}' in '{script_path}' is not callable" + f"Function 'html_hook' not found or not callable in simplepdf_html_hook script: {script_path}" ) return hook_func @@ -301,7 +284,7 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: """ - def _toctree_fix_soup(self, soup: BeautifulSoup) -> BeautifulSoup: + def _toctree_fix(self, soup: BeautifulSoup) -> BeautifulSoup: """Fix toctree page numbering issues for documents with duplicate chapter names. Args: From dfbc73fa5a27bade297c399b55fee35a8b09cdbd Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 12:16:50 -0700 Subject: [PATCH 03/10] Drop the version bump from this PR --- docs/changelog.rst | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bb7ea79..869b7dd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,8 +1,8 @@ Changelog ========= -Release 1.8 ------------ +Unreleased +---------- * **Enhancement**: Add the ability to configure an :ref:`simplepdf_html_hook` to modify the HTML before PDF generation. diff --git a/pyproject.toml b/pyproject.toml index 92d772a..ec0d430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "sphinx-simplepdf" -version = "1.8.0" +version = "1.7.0" description = "An easy to use PDF Builder for Sphinx with a modern PDF-Theme." readme = "README.rst" license = { text = "MIT" } From efb55196cb53973fa4c5ab8ed844ca55395bd0d4 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 12:34:51 -0700 Subject: [PATCH 04/10] run ruff formatting --- sphinx_simplepdf/builders/simplepdf.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 8bd48e0..06c30db 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -1,10 +1,10 @@ -import importlib.util from collections import Counter +from collections.abc import Callable +import importlib.util import os import re import subprocess -from collections import Counter -from typing import Any, Callable +from typing import Any from bs4 import BeautifulSoup import sass @@ -198,9 +198,7 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | # Check if file exists if not os.path.isfile(script_path): - raise ConfigError( - f"simplepdf_html_hook script not found: {script_path}" - ) + raise ConfigError(f"simplepdf_html_hook script not found: {script_path}") # Load the module spec = importlib.util.spec_from_file_location("simplepdf_hook", script_path) @@ -208,9 +206,7 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | try: spec.loader.exec_module(module) except Exception as e: - raise ConfigError( - f"Error loading simplepdf_html_hook script '{script_path}': {e}" - ) from 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): @@ -241,22 +237,16 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: try: result = hook_func(soup, self.app) except Exception as e: - raise ExtensionError( - f"simplepdf_html_hook raised an exception: {e}" - ) from e + raise ExtensionError(f"simplepdf_html_hook raised an exception: {e}") from e if result is None: logger.warning( - "simplepdf_html_hook returned None, using original HTML. " - "The hook should return a BeautifulSoup object." + "simplepdf_html_hook returned None, using original HTML. The hook should return a BeautifulSoup object." ) return soup if not isinstance(result, BeautifulSoup): - raise ExtensionError( - f"simplepdf_html_hook must return a BeautifulSoup object, " - f"got {type(result).__name__}" - ) + raise ExtensionError(f"simplepdf_html_hook must return a BeautifulSoup object, got {type(result).__name__}") return result From 6a8e2c813bad0f318947d7fa7267663db9a737a3 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 12:40:21 -0700 Subject: [PATCH 05/10] Make the None return an error, not a warning --- docs/configuration.rst | 7 ------- sphinx_simplepdf/builders/simplepdf.py | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 8558c1c..2edee06 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -248,10 +248,3 @@ The ``html_hook`` function must accept two arguments: The function must return a ``BeautifulSoup`` object. -**Error handling:** - -- If the script file is not found, a ``ConfigError`` is raised -- If the function is not found in the script, a ``ConfigError`` is raised -- If the hook returns ``None``, a warning is logged and the original HTML is used -- If the hook returns a non-BeautifulSoup type, an error is raised -- If the hook raises an exception, it is wrapped in an ``ExtensionError`` diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 06c30db..49fdb42 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -226,7 +226,8 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: The modified BeautifulSoup object. Raises: - ExtensionError: If the hook raises an exception or returns an invalid type. + ExtensionError: If the hook raises an exception, returns ``None``, or returns a value + that is not a ``BeautifulSoup`` instance. """ hook_func = self._load_html_hook() if hook_func is None: @@ -240,10 +241,9 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: raise ExtensionError(f"simplepdf_html_hook raised an exception: {e}") from e if result is None: - logger.warning( - "simplepdf_html_hook returned None, using original HTML. The hook should return a BeautifulSoup object." + raise ExtensionError( + "simplepdf_html_hook returned None. The hook must return a BeautifulSoup object." ) - return soup if not isinstance(result, BeautifulSoup): raise ExtensionError(f"simplepdf_html_hook must return a BeautifulSoup object, got {type(result).__name__}") From 5110ecb50787e404eebf2732153b94682a79c218 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 13:02:41 -0700 Subject: [PATCH 06/10] Load the hook once in __init__, not on every call to _execute_html_hook --- sphinx_simplepdf/builders/simplepdf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 49fdb42..8a69533 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -76,6 +76,10 @@ 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. @@ -229,7 +233,7 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: ExtensionError: If the hook raises an exception, returns ``None``, or returns a value that is not a ``BeautifulSoup`` instance. """ - hook_func = self._load_html_hook() + hook_func = self._html_hook_func if hook_func is None: return soup From 80c7ee57aab60a48e41fd6b73f0d37269056b0d6 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 13:05:19 -0700 Subject: [PATCH 07/10] Guard against spec_from_file_location returning None and use virtual module name --- sphinx_simplepdf/builders/simplepdf.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 8a69533..05a33f7 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -204,8 +204,13 @@ def _load_html_hook(self) -> Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | if not os.path.isfile(script_path): raise ConfigError(f"simplepdf_html_hook script not found: {script_path}") - # Load the module - spec = importlib.util.spec_from_file_location("simplepdf_hook", 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) From b229cc0be668e5cd00fbfba063ca09cc66ce6c36 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 13:12:35 -0700 Subject: [PATCH 08/10] fix mypy and ruff now that i have pre-commit installed --- sphinx_simplepdf/builders/simplepdf.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 05a33f7..0ac9b2f 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -7,6 +7,7 @@ from typing import Any from bs4 import BeautifulSoup +from bs4.element import Tag import sass from sphinx import __version__ from sphinx.application import Sphinx @@ -29,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}") @@ -76,9 +77,7 @@ def __init__(self, *args, **kwargs): }, ) - self._html_hook_func: Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | None = ( - self._load_html_hook() - ) + self._html_hook_func: Callable[[BeautifulSoup, Sphinx], BeautifulSoup] | None = self._load_html_hook() def get_config_var(self, name, default): """ @@ -250,9 +249,7 @@ def _execute_html_hook(self, soup: BeautifulSoup) -> BeautifulSoup: 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." - ) + 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__}") @@ -297,7 +294,7 @@ def _toctree_fix(self, soup: BeautifulSoup) -> BeautifulSoup: 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 From eaf2a07ecb099cfb0ec5df7ab96dc1cd798e4a6c Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 14:18:47 -0700 Subject: [PATCH 09/10] Add tests --- tests/doc_test/html_hook/conf.py | 9 +++++ tests/doc_test/html_hook/hooks/missing_def.py | 2 + tests/doc_test/html_hook/hooks/ok.py | 5 +++ .../doc_test/html_hook/hooks/returns_none.py | 5 +++ tests/doc_test/html_hook/index.rst | 9 +++++ tests/test_html_hook.py | 37 +++++++++++++++++++ 6 files changed, 67 insertions(+) create mode 100644 tests/doc_test/html_hook/conf.py create mode 100644 tests/doc_test/html_hook/hooks/missing_def.py create mode 100644 tests/doc_test/html_hook/hooks/ok.py create mode 100644 tests/doc_test/html_hook/hooks/returns_none.py create mode 100644 tests/doc_test/html_hook/index.rst create mode 100644 tests/test_html_hook.py 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() From 6ba694df7911931dff143c62fd2932ee3d2effa0 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 14:25:27 -0700 Subject: [PATCH 10/10] Add a note that it's intentionally single-hook --- docs/configuration.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 2edee06..e1b1419 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -200,6 +200,9 @@ Path to a Python script that will be called to manipulate the HTML before PDF ge 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. @@ -247,4 +250,3 @@ The ``html_hook`` function must accept two arguments: :app: The Sphinx application instance (provides access to ``config``, ``srcdir``, ``outdir``, etc.) The function must return a ``BeautifulSoup`` object. -