diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index ee6bfb8..7ff23e8 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -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`` ------------------ diff --git a/doc/manual/howto/publish-a-deploy-tree.rst b/doc/manual/howto/publish-a-deploy-tree.rst index a5487f6..bec33f1 100644 --- a/doc/manual/howto/publish-a-deploy-tree.rst +++ b/doc/manual/howto/publish-a-deploy-tree.rst @@ -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. diff --git a/scripts/docrefs.py b/scripts/docrefs.py index dd72900..1045e42 100644 --- a/scripts/docrefs.py +++ b/scripts/docrefs.py @@ -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 -> @@ -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 []), } @@ -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( @@ -1029,22 +1035,23 @@ 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=""``, 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 "/". base_dir_url = _intersphinx_target_dir(meta["remote-url"]).rstrip("/") @@ -1052,9 +1059,9 @@ def tag_urls(documents, deploy, rel_urls): 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 diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py new file mode 100644 index 0000000..f35e100 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py @@ -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")] diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst new file mode 100644 index 0000000..b7a1f78 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst @@ -0,0 +1,12 @@ +PDF links +========= + +.. _target: + +A local server: `peer `_. + +A relative page: `listing <../../dox-api/main_8c_source.html#l00061>`_. + +A published site: `site `_. + +Inside the document: :ref:`the target `. diff --git a/sphinx/_extensions/_tests/test_adequacy.py b/sphinx/_extensions/_tests/test_adequacy.py index 334c735..151d6f4 100644 --- a/sphinx/_extensions/_tests/test_adequacy.py +++ b/sphinx/_extensions/_tests/test_adequacy.py @@ -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): diff --git a/sphinx/_extensions/_tests/test_needs_config_state.py b/sphinx/_extensions/_tests/test_needs_config_state.py index c6a3ad0..2cce296 100644 --- a/sphinx/_extensions/_tests/test_needs_config_state.py +++ b/sphinx/_extensions/_tests/test_needs_config_state.py @@ -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 = { diff --git a/sphinx/_extensions/_tests/test_pdf_links.py b/sphinx/_extensions/_tests/test_pdf_links.py new file mode 100644 index 0000000..4b32ea2 --- /dev/null +++ b/sphinx/_extensions/_tests/test_pdf_links.py @@ -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() diff --git a/sphinx/_extensions/needs_config_state.py b/sphinx/_extensions/needs_config_state.py index 964ea79..9bd109e 100644 --- a/sphinx/_extensions/needs_config_state.py +++ b/sphinx/_extensions/needs_config_state.py @@ -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 @@ -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 "" diff --git a/sphinx/_extensions/pdf_links.py b/sphinx/_extensions/pdf_links.py new file mode 100644 index 0000000..097e26a --- /dev/null +++ b/sphinx/_extensions/pdf_links.py @@ -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} diff --git a/sphinx/_extensions/symbol_needs.py b/sphinx/_extensions/symbol_needs.py index 14f6575..de83215 100644 --- a/sphinx/_extensions/symbol_needs.py +++ b/sphinx/_extensions/symbol_needs.py @@ -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 @@ -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), diff --git a/sphinx/_extensions/test_coverage.py b/sphinx/_extensions/test_coverage.py index ccf1812..795c3c0 100644 --- a/sphinx/_extensions/test_coverage.py +++ b/sphinx/_extensions/test_coverage.py @@ -27,7 +27,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList -from doxygen_parser import SourcePages +from doxygen_parser import SourcePages, page_url from input_tracking import _note_input from needs_fields import field_type from rst_builders import _need_name @@ -365,7 +365,7 @@ def _body_url(self, env): continue if doc_id not in cache: cache[doc_id] = SourcePages(doc["xml_dir"]) - listings.append((cache[doc_id], page_prefix + doc["html_url"])) + listings.append((cache[doc_id], page_url(page_prefix, doc["html_url"]))) def body_url(path, line): for pages, html_dir in listings: diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 59f0414..c7ee0f2 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -10,7 +10,13 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList -from doxygen_parser import SourcePages, detail_rst_lines, load_group_index, parse_memberdef +from doxygen_parser import ( + SourcePages, + detail_rst_lines, + load_group_index, + page_url, + parse_memberdef, +) from input_tracking import ( # noqa: F401 (the other hooks are re-exported for tests) _merge_inputs, _note_input, @@ -586,8 +592,8 @@ def run(self): page_depth = len(Path(env.docname).parts) - 1 page_prefix = "../" * page_depth - testspec_html_dir = page_prefix + app.config.testspec_doxygen_url - api_html_dir = page_prefix + app.config.api_doxygen_url + testspec_html_dir = page_url(page_prefix, app.config.testspec_doxygen_url) + api_html_dir = page_url(page_prefix, app.config.api_doxygen_url) tag_dirs = _tag_dirs(getattr(app.config, "testmodule_tag_urls", {}), page_prefix) # testmodule_root is supplied by the engine (zdocs_conf.py, defaulting # to ZDOCS_PROJECT_BASE) — no ZEPHYR_BASE fallback: that was a diff --git a/sphinx/_scripts/_tests/test_doxygen_parser.py b/sphinx/_scripts/_tests/test_doxygen_parser.py index d6110b9..f0e43c2 100644 --- a/sphinx/_scripts/_tests/test_doxygen_parser.py +++ b/sphinx/_scripts/_tests/test_doxygen_parser.py @@ -664,6 +664,19 @@ def test_source_pages_take_the_file_compound_of_the_path(tmp_path): ) +@pytest.mark.parametrize( + "url, seen", + [ + ("../dox-api", "../../../dox-api"), + ("https://docs.example.org/html/dox-api", "https://docs.example.org/html/dox-api"), + ("/html/dox-api", "/html/dox-api"), + ("", ""), + ], +) +def test_page_url_prefixes_only_a_relative_url(url, seen): + assert dp.page_url("../../", url) == seen + + def test_source_pages_report_the_files_they_read(tmp_path): xml = _run(tmp_path, files={"a_8c": "src/a.c", "b_8c": "src/b.c"}) pages = dp.SourcePages(xml) diff --git a/sphinx/_scripts/doxygen_parser.py b/sphinx/_scripts/doxygen_parser.py index 8175d5f..b63cd2a 100644 --- a/sphinx/_scripts/doxygen_parser.py +++ b/sphinx/_scripts/doxygen_parser.py @@ -692,6 +692,15 @@ def member_source(memberdef, html_dir, source_pages=None): return f"{Path(fpath).name} (line {line})", url +def page_url(page_prefix: str, url: str) -> str: + """``url`` as seen from a page ``page_prefix`` (``../`` per level) below + the document root: a relative URL gets the prefix, an absolute one (a + peer's site in a PDF build, ``https://...``) stays as it is.""" + if not url or "://" in url or url.startswith("/"): + return url + return page_prefix + url + + #: Doxygen writes no listing for a documentation file. _DOC_FILES = (".dox", ".md", ".markdown", ".txt", ".doc") #: The files that ``VERBATIM_HEADERS`` gives a listing. diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 13b8485..3f1a34f 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -139,6 +139,9 @@ def configure( # type, from the needs configuration (one small CSS file), and a class # for the status of each need. "need_cards", + # In a PDF, a link to a local server or a relative HTML path (which + # has no target next to the PDF) is written as text, with a warning. + "pdf_links", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by