Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"]``.
Expand Down
59 changes: 59 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
107 changes: 100 additions & 7 deletions sphinx_simplepdf/builders/simplepdf.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests. The project has no test harness, I know. But _load_html_hook is testable in isolation — give it a temp file with a function, assert it loads. Give it a file without html_hook, assert it raises. Give it a nonexistent path, assert it raises. Four lines each. Even without a formal test framework, a if __name__ == "__main__" smoke test at the bottom of the module would be better than nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged main, since the test framework was added, and added tests in eaf2a07

Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from collections import Counter
from collections.abc import Callable
import importlib
import os
import re
import subprocess
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

Expand All @@ -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}")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions tests/doc_test/html_hook/conf.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions tests/doc_test/html_hook/hooks/missing_def.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def not_html_hook() -> None:
pass
5 changes: 5 additions & 0 deletions tests/doc_test/html_hook/hooks/ok.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from bs4 import BeautifulSoup


def html_hook(soup: BeautifulSoup, app: object) -> BeautifulSoup:
return soup
5 changes: 5 additions & 0 deletions tests/doc_test/html_hook/hooks/returns_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from bs4 import BeautifulSoup


def html_hook(soup: BeautifulSoup, app: object) -> None:
return None
9 changes: 9 additions & 0 deletions tests/doc_test/html_hook/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
HTML hook test document
=======================

Minimal content for SimplePDF hook tests.

Section
-------

Body.
37 changes: 37 additions & 0 deletions tests/test_html_hook.py
Original file line number Diff line number Diff line change
@@ -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()
Loading