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
9 changes: 9 additions & 0 deletions doc/api/python/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ need cards.
.. automodule:: need_cards
:members:

``pdf_links``
---------------

Writes a link that is dead in a PDF (a local server, a relative HTML path) as
its text.

.. automodule:: pdf_links
:members:

``xref_builder``
------------------

Expand Down
17 changes: 17 additions & 0 deletions doc/manual/howto/publish-a-deploy-tree.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ file wherever it is filed or mailed, so a PDF built against a development
``base_url`` points at a host that may not exist — permanently, in a document
that could already be signed off. Build (or rebuild) with the production
``base_url`` before generating a PDF meant for distribution or signature.

The links of a PDF to other documents use the ``base_url``: the links of the
needs, and the links to Doxygen pages (Source, See also, Declared in). Two
kinds of link would be dead in a PDF: a link to a local server
(``localhost`` or a loopback address, such as a development ``base_url``)
and a relative HTML path, which has no target next to the PDF. The
``pdf_links`` extension writes each of them as its text, without the link,
and warns once per document::

WARNING: pdf_links: 72 link(s) written as text, without the link: they go
to a local server or to an HTML path next to the PDF (the first:
http://localhost/html/...). Set ZDOCS_DOC_BASE_URL to the published
site to keep the links to other documents. [zdocs.pdf_links]

So a PDF of a development build has no dead links, only fewer links. A new
``ZDOCS_DOC_BASE_URL`` takes effect on the next incremental build: the
imported needs are read again with the new URL.
35 changes: 21 additions & 14 deletions scripts/docrefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,12 @@ def _peer_url(doc_id, meta):
return rel_urls[doc_id]
return base_url + meta.get("path", f"html/{doc_id}")

# The URL by which this document's output links into a locally built
# peer: ``rel_urls`` in HTML, the ``base_url``-rooted URL in a PDF, which
# has no sibling tree to be relative to. The extensions put the page's
# ``../`` prefix only in front of a relative one.
link_urls = {doc_id: _peer_url(doc_id, documents[doc_id]) for doc_id in rel_urls}

# Nav links, grouped by the registry's `groups:`. sphinx/doxygen -> the
# peer's index page (relative ones are relative to this doc's html root;
# layout.html prefixes them with the page's own content_root); external ->
Expand Down Expand Up @@ -992,10 +998,10 @@ def _nav_href(doc_id, meta):

testmodule = {
"xml_dir": xml_dir,
"doxygen_url": rel_urls.get(doxygen_source, "") if doxygen_source else "",
"api_url": rel_urls.get(api_reference, "") if api_reference else "",
"doxygen_url": link_urls.get(doxygen_source, "") if doxygen_source else "",
"api_url": link_urls.get(api_reference, "") if api_reference else "",
"needs_json": needs_json,
"tag_urls": tag_urls(documents, deploy, rel_urls),
"tag_urls": tag_urls(documents, deploy, link_urls),
"coverage_source_docs": list(testmodule_block.get("coverage_source_docs") or []),
}

Expand All @@ -1005,13 +1011,13 @@ def _nav_href(doc_id, meta):
source = symbol_block["doxygen_source"]
symbol_needs = {
"xml_dir": str(deploy / "xml" / source),
"doxygen_url": rel_urls.get(source, ""),
"doxygen_url": link_urls.get(source, ""),
}

doxygen_docs = {
doc_id: {"xml_dir": str(deploy / "xml" / doc_id), "html_url": rel_urls[doc_id]}
doc_id: {"xml_dir": str(deploy / "xml" / doc_id), "html_url": link_urls[doc_id]}
for doc_id, meta in documents.items()
if meta.get("kind") == "doxygen" and doc_id in rel_urls
if meta.get("kind") == "doxygen" and doc_id in link_urls
}

return Refs(
Expand All @@ -1029,32 +1035,33 @@ def _nav_href(doc_id, meta):
)


def tag_urls(documents, deploy, rel_urls):
def tag_urls(documents, deploy, link_urls):
"""``{tag file path: HTML directory URL}`` for every tag file `tagfiles` can name.

Doxygen marks a reference it resolved through a tag file with
``external="<tag file path>"``, the path exactly as ``TAGFILES`` gave it.
This map sends such a reference to the document whose tag file it is:
for a ``kind: doxygen`` peer its HTML directory relative to this
document's root (``rel_urls``), for a ``kind: doxygen-external`` peer its
absolute remote directory, and for a needs tag its Sphinx root.
for a ``kind: doxygen`` peer its HTML directory (``link_urls``: relative
to this document's root in HTML, absolute in a PDF), for a
``kind: doxygen-external`` peer its absolute remote directory, and for a
needs tag its Sphinx root.
"""
urls = {}
for doc_id, meta in documents.items():
kind = meta.get("kind")
path = meta.get("path", f"html/{doc_id}")
if kind == "doxygen" and doc_id in rel_urls:
urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = rel_urls[doc_id]
if kind == "doxygen" and doc_id in link_urls:
urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = link_urls[doc_id]
elif kind == "doxygen-external":
# Without its trailing slash: the reader appends "/<page>".
base_dir_url = _intersphinx_target_dir(meta["remote-url"]).rstrip("/")
urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = base_dir_url
elif (
kind in (None, "sphinx")
and meta.get("doxygen_tag") is not None
and doc_id in rel_urls
and doc_id in link_urls
):
urls[(deploy / path / NEEDS_TAGFILE).as_posix()] = rel_urls[doc_id]
urls[(deploy / path / NEEDS_TAGFILE).as_posix()] = link_urls[doc_id]
return urls


Expand Down
9 changes: 9 additions & 0 deletions sphinx/_extensions/_tests/roots/test-pdflinks/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # _extensions/

extensions = ["pdf_links"]
master_doc = "index"
exclude_patterns = ["_build"]
latex_documents = [("index", "pdflinks.tex", "PDF links", "zdocs", "howto")]
12 changes: 12 additions & 0 deletions sphinx/_extensions/_tests/roots/test-pdflinks/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
PDF links
=========

.. _target:

A local server: `peer <http://localhost:8000/html/peer/index.html#REQ-1>`_.

A relative page: `listing <../../dox-api/main_8c_source.html#l00061>`_.

A published site: `site <https://example.org/html/peer/index.html#REQ-1>`_.

Inside the document: :ref:`the target <target>`.
9 changes: 9 additions & 0 deletions sphinx/_extensions/_tests/test_adequacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,15 @@ def test_registry_gives_the_coverage_source_docs(tmp_path, monkeypatch):
refs = docrefs.load("report", _registry(tmp_path))
assert refs.testmodule["coverage_source_docs"] == ["dox-dd", "dox-api"]
assert set(refs.doxygen_docs) == {"dox-dd", "dox-api"}
assert refs.doxygen_docs["dox-dd"]["html_url"] == "../dox-dd"


def test_a_pdf_links_the_doxygen_documents_by_the_base_url(tmp_path, monkeypatch):
# A PDF has no sibling tree: a relative ../dox-dd would be dead in it.
monkeypatch.setenv("OUTPUT_DIR", str(tmp_path / "deploy" / "pdf" / "report"))
monkeypatch.setenv("ZDOCS_DOC_BASE_URL", "https://docs.example.org/v1")
refs = docrefs.load("report", _registry(tmp_path))
assert refs.doxygen_docs["dox-dd"]["html_url"] == "https://docs.example.org/v1/html/dox-dd"


def test_manifest_names_the_coverage_source_docs(tmp_path):
Expand Down
13 changes: 13 additions & 0 deletions sphinx/_extensions/_tests/test_needs_config_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,19 @@ def test_imported_digest_follows_the_imported_needs(tmp_path):
assert ncs.imported_needs_digest(tmp_path, sources) != first


def test_imported_digest_follows_the_base_url(tmp_path):
# The environment keeps each imported need with its URL: a PDF built
# after a new ZDOCS_DOC_BASE_URL linked the old one.
peer = tmp_path / "needs.json"
_needs_json(peer, {"SPEC_001": {"id": "SPEC_001"}})
def digest(base_url):
return ncs.imported_needs_digest(tmp_path, [{"json_path": str(peer), "base_url": base_url}])

local = digest("http://localhost:8000/html/spec")
site = digest("https://docs.example.org/html/spec")
assert local != site


def test_imported_digest_reads_the_configured_version(tmp_path):
peer = tmp_path / "needs.json"
data = {
Expand Down
78 changes: 78 additions & 0 deletions sphinx/_extensions/_tests/test_pdf_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0

"""pdf_links: no link to a local server or a relative HTML path in a PDF."""

from pathlib import Path

import pytest
from pdf_links import dead_in_pdf

_ROOT = str(Path(__file__).parent / "roots" / "test-pdflinks")


@pytest.mark.parametrize(
"uri, dead",
[
("http://localhost:8000/html/peer/index.html#REQ-1", True),
("http://127.0.0.1:8001/x.html", True),
("http://127.0.0.2/x.html", True),
("http://[::1]:8000/x.html", True),
("http://0.0.0.0:8000/x.html", True),
("https://localhost.example.org/x.html", False),
("https://192.0.2.1/x.html", False),
("file:///tmp/x.html", True),
("../../dox-api/main_8c_source.html#l00061", True),
("https://example.org/html/peer/index.html#REQ-1", False),
("mailto:someone@example.org", False),
("#REQ-1", False),
("%index#target", False),
("", False),
],
)
def test_dead_in_pdf(uri, dead):
assert dead_in_pdf(uri) is dead


@pytest.mark.sphinx("latex", srcdir=_ROOT)
def test_a_pdf_has_no_dead_link(app):
app.build()
tex = (Path(app.outdir) / "pdflinks.tex").read_text()
assert "localhost" not in tex and "main\\_8c\\_source" not in tex
assert "peer" in tex and "listing" in tex # the text stays
assert "\\sphinxhref{https://example.org/html/peer/index.html\\#REQ-1}{site}" in tex
assert "\\hyperref[\\detokenize{index:target}]" in tex
warnings = app._warning.getvalue()
assert "pdf_links: 2 link(s) written as text" in warnings
assert "http://localhost:8000/html/peer/index.html#REQ-1" in warnings


def test_a_link_marked_internal_with_an_external_target_is_text_too():
# sphinx-needs marks a needtable link "internal" by the row's need.
from types import SimpleNamespace

from docutils import nodes
from docutils.utils import new_document
from pdf_links import _unlink

doc = new_document("index")
para = nodes.paragraph()
para += nodes.reference(
"", "TC_1", internal=True, refuri="http://localhost:8000/html/spec/index.html#TC_1"
)
para += nodes.reference("", "here", internal=True, refuri="%index#target")
doc += para
_unlink(SimpleNamespace(builder=SimpleNamespace(format="latex")), doc, "index")
refs = list(doc.findall(nodes.reference))
assert [r["refuri"] for r in refs] == ["%index#target"]
assert para.astext() == "TC_1here"


@pytest.mark.sphinx("html", srcdir=_ROOT)
def test_html_keeps_every_link(app):
app.build()
html = (Path(app.outdir) / "index.html").read_text()
assert 'href="http://localhost:8000/html/peer/index.html#REQ-1"' in html
assert 'href="../../dox-api/main_8c_source.html#l00061"' in html
assert "pdf_links" not in app._warning.getvalue()
11 changes: 10 additions & 1 deletion sphinx/_extensions/needs_config_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ def imported_needs_digest(confdir, external_needs):

Only ``json_path`` sources count. A relative path is resolved against
``confdir``, as sphinx-needs resolves it. A ``json_url`` source is not read.

The settings of each source (``base_url`` and the others) count too: the
pickled environment keeps each imported need with its URL. Without them,
a new ``ZDOCS_DOC_BASE_URL`` left the links of a PDF on the old one.
"""
sha = hashlib.sha256()
count = 0
Expand All @@ -112,7 +116,12 @@ def imported_needs_digest(confdir, external_needs):
if not json_path:
continue
path = Path(confdir, json_path)
sha.update(f"{path}\0{_imported_content(path, source.get('version'))}\0".encode())
settings = json.dumps(
{k: v for k, v in source.items() if k != "json_path"}, sort_keys=True, default=str
)
sha.update(
f"{path}\0{settings}\0{_imported_content(path, source.get('version'))}\0".encode()
)
count += 1
return sha.hexdigest() if count else ""

Expand Down
84 changes: 84 additions & 0 deletions sphinx/_extensions/pdf_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0

"""Sphinx extension: no dead external link in a PDF.

A PDF has no sibling tree of HTML pages. Two kinds of link from the HTML
world are dead in it:

- a relative link, such as ``../../dox-api/main_8c_source.html``: it
resolves against the folder of the PDF, where no such page is;
- a link to a local server (``localhost`` or a loopback address): the
registry's ``base_url`` of a development build.

In a LaTeX build, this extension writes such a link as its text, without the
link, and warns once per document. Set ``ZDOCS_DOC_BASE_URL`` to the
published site to keep the links to other documents. Links inside the PDF and
links to another host stay as they are.

It runs on ``doctree-resolved``, after sphinx-needs has written the links of
the needs (doctree-resolved at the default priority 500).
"""

import ipaddress
from urllib.parse import urlsplit

from docutils import nodes

from sphinx.util import logging

logger = logging.getLogger(__name__)


def local_host(host):
"""Whether ``host`` is this machine: ``localhost``, a loopback address or
the unspecified address."""
if host == "localhost":
return True
try:
address = ipaddress.ip_address(host or "")
except ValueError:
return False
return address.is_loopback or address.is_unspecified


def dead_in_pdf(uri):
"""Whether ``uri`` (a ``refuri``) has no target from a PDF."""
if not uri or uri.startswith(("#", "%")): # inside the document
return False
parts = urlsplit(uri)
if parts.scheme in ("http", "https"):
return local_host(parts.hostname)
if parts.scheme == "file":
return True
return not parts.scheme # a relative path; mailto: and the like stay


def _unlink(app, doctree, docname):
if app.builder.format != "latex":
return
# Not by the "internal" flag: sphinx-needs sets it on a needtable link to
# a need of another document too. In LaTeX, a link inside the document
# starts with "%" or "#", which dead_in_pdf keeps.
dead = [
ref for ref in doctree.findall(nodes.reference)
if not ref.get("refid") and dead_in_pdf(ref.get("refuri"))
]
for ref in dead:
ref.replace_self(nodes.inline(ref.rawsource, "", *ref.children, classes=ref["classes"]))
if dead:
logger.warning(
f"pdf_links: {len(dead)} link(s) written as text, without the link: they "
f"go to a local server or to an HTML path next to the PDF (the first: "
f"{dead[0].get('refuri')}). Set ZDOCS_DOC_BASE_URL to the published site "
f"to keep the links to other documents.",
location=docname,
type="zdocs",
subtype="pdf_links",
)


def setup(app):
app.connect("doctree-resolved", _unlink, priority=900)
return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}
4 changes: 2 additions & 2 deletions sphinx/_extensions/symbol_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from docutils import nodes
from docutils.parsers.rst import Directive
from docutils.statemachine import ViewList
from doxygen_parser import SourcePages, load_group_index, parse_symbol
from doxygen_parser import SourcePages, load_group_index, page_url, parse_symbol
from input_tracking import _note_input
from needs_fields import depends_field
from rst_builders import build_symbol_need_rst
Expand Down Expand Up @@ -134,7 +134,7 @@ def run(self):

page_prefix = "../" * (len(Path(env.docname).parts) - 1)
doxygen_url = config.symbolneeds_doxygen_url
html_dir = page_prefix + doxygen_url if doxygen_url else ""
html_dir = page_url(page_prefix, doxygen_url)
try:
rst = symbol_needs_rst(
xml_dir, html_dir, group, _need_names_from_config(config),
Expand Down
Loading