diff --git a/cmake/sphinx.cmake b/cmake/sphinx.cmake
index fbcd716..5f55e2a 100644
--- a/cmake/sphinx.cmake
+++ b/cmake/sphinx.cmake
@@ -276,6 +276,17 @@ function(add_sphinx_target doc_name)
add_dependencies(${doc_name}-index ${dep}-index)
endforeach()
+ # The PDF's name in the set comes from the registry entry (title) and the
+ # version, which follows the tags of ZDOCS_PROJECT_BASE.
+ set(_zdocs_pdf_args "")
+ if(ARGS_REGISTRY)
+ list(APPEND _zdocs_pdf_args --registry ${ARGS_REGISTRY})
+ endif()
+ set(_zdocs_pdf_deliver_args ${_zdocs_pdf_args})
+ if(NOT ZDOCS_PROJECT_BASE STREQUAL "")
+ list(APPEND _zdocs_pdf_deliver_args --repo-root ${ZDOCS_PROJECT_BASE})
+ endif()
+
foreach(builder ${ARGS_BUILDERS})
set(target_name ${doc_name}-${builder})
if(builder STREQUAL "latex")
@@ -284,6 +295,12 @@ function(add_sphinx_target doc_name)
set(builder_folder "${builder}")
endif()
set(builder_out_dir ${CMAKE_CURRENT_BINARY_DIR}/deploy/${builder_folder}/${doc_name})
+ if(builder STREQUAL "latex")
+ # The LaTeX build directory is a work area, not a deliverable: only its
+ # PDF goes into deploy/pdf, the PDF set, as
_.pdf
+ # (docrefs.py pdf-deliver, below).
+ set(builder_out_dir ${DOC_OUT_DIR}/latex)
+ endif()
set(sphinx_executable ${SPHINXBUILD})
set(extra_sphinx_args "")
set(extra_commands "")
@@ -337,6 +354,18 @@ function(add_sphinx_target doc_name)
${LATEXMK}
-pdf
${doc_name}.tex
+ COMMAND
+ ${CMAKE_COMMAND}
+ -E
+ env
+ ${SPHINX_ENV}
+ ${PYTHON_EXECUTABLE}
+ ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../scripts/docrefs.py
+ pdf-deliver
+ ${doc_name}
+ ${builder_out_dir}/${doc_name}.pdf
+ ${CMAKE_CURRENT_BINARY_DIR}/deploy/pdf
+ ${_zdocs_pdf_deliver_args}
)
else()
set(builder_arg ${builder})
@@ -396,16 +425,24 @@ function(add_sphinx_target doc_name)
# Per-document clean. Keeps ${DOC_OUT_DIR}/.git (the configure-time sphinx-git
# pointer) so a rebuild works without reconfiguring.
set(_zdocs_clean_deploy_dirs ${CMAKE_CURRENT_BINARY_DIR}/deploy/html/${doc_name})
+ set(_zdocs_clean_pdf "")
foreach(builder ${ARGS_BUILDERS})
if(builder STREQUAL "latex")
- set(_zdocs_clean_folder "pdf")
+ # The LaTeX work area, and this document's PDFs in the PDF set.
+ list(APPEND _zdocs_clean_deploy_dirs ${DOC_OUT_DIR}/latex)
+ set(
+ _zdocs_clean_pdf
+ COMMAND
+ ${PYTHON_EXECUTABLE}
+ ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../scripts/docrefs.py
+ pdf-remove
+ ${doc_name}
+ ${CMAKE_CURRENT_BINARY_DIR}/deploy/pdf
+ ${_zdocs_pdf_args}
+ )
else()
- set(_zdocs_clean_folder "${builder}")
+ list(APPEND _zdocs_clean_deploy_dirs ${CMAKE_CURRENT_BINARY_DIR}/deploy/${builder}/${doc_name})
endif()
- list(
- APPEND _zdocs_clean_deploy_dirs
- ${CMAKE_CURRENT_BINARY_DIR}/deploy/${_zdocs_clean_folder}/${doc_name}
- )
endforeach()
list(REMOVE_DUPLICATES _zdocs_clean_deploy_dirs)
@@ -414,6 +451,7 @@ function(add_sphinx_target doc_name)
COMMAND
${CMAKE_COMMAND} -E rm -rf ${DOCS_SRC_DIR} ${DOCS_DOCTREE_DIR} ${DOCS_INDEX_DOCTREE_DIR}
${DOC_OUT_DIR}/doctrees-latex ${DOCS_BUILD_DIR} ${_zdocs_clean_deploy_dirs}
+ ${_zdocs_clean_pdf}
COMMENT "Cleaning document ${doc_name}..."
)
add_dependencies(clean-docs ${doc_name}-clean)
diff --git a/doc/manual/explanation/decisions/0013-linked-pdf-set.rst b/doc/manual/explanation/decisions/0013-linked-pdf-set.rst
new file mode 100644
index 0000000..691ac1d
--- /dev/null
+++ b/doc/manual/explanation/decisions/0013-linked-pdf-set.rst
@@ -0,0 +1,63 @@
+0013. The PDFs of a document set link to each other
+====================================================
+
+Status
+------
+
+Accepted, 2026-10-14. Changes the ``deploy/pdf`` part of
+:doc:`0006-deploy-tree-by-builder`.
+
+Context
+-------
+
+A PDF leaves the deploy tree: it is mailed, filed and signed. Its links to
+other documents went to the published HTML site (the registry's
+``base_url``), so a reader needed the site and a network, and a link from a
+released PDF could open a newer version of the target. A PDF of a
+development build linked a local server, or nothing (``pdf_links``).
+
+``deploy/pdf//`` held the whole LaTeX build directory: the .tex,
+the auxiliary files and the PDF, which Sphinx names after the project title.
+Nothing in it was meant for a reader except the PDF.
+
+Decision
+--------
+
+The PDFs of a set are one deliverable that works offline and links within
+itself:
+
+- ``deploy/pdf`` is the PDF set: one ``_.pdf`` per document
+ with a ``latex`` builder, side by side. The title is the registry
+ ``title:`` (white space as ``-``), the version is the one the document
+ shows (its ``version_scope``). ``docrefs.py pdf-deliver`` copies latexmk's
+ PDF there and removes the other versions of the same document. Two
+ documents with the same title part are a configure-time error.
+- The LaTeX build directory is a work area at ``//latex``,
+ outside the deploy tree.
+- Each LaTeX label is a named destination of the same name (hyperref's
+ ``destlabel``). Sphinx writes a label for each need, section and page:
+ ``:``.
+- A link to a document of the set becomes a link to its PDF:
+ ``_.pdf#:``, which hyperref writes as a
+ "go to remote" action (file and named destination). A link to another
+ document keeps the rules before: the ``base_url``, or text when that is a
+ local server (``pdf_links``).
+- ``doc-check`` reads the PDFs and fails on a link into the set whose file or
+ destination does not exist.
+
+Consequences
+------------
+
+- The set works offline and in one version: each link names the file of the
+ version it was built with. A new version of one document renames its file,
+ so the documents that link to it must be built again (the set is built and
+ released together).
+- The links work only while the files keep their names and stay in one
+ folder. Deliver the set as one unit (a folder or an archive); a system that
+ renames the files breaks the links.
+- Viewer support can differ. Evince follows the links, and pdf.js opens a
+ PDF at a named destination. The "go to remote" links were not tested in
+ browser viewers.
+- Doxygen documents have no PDF; links to them stay web links or text.
+- A consumer that picked up ``deploy/pdf//.pdf`` must take
+ ``deploy/pdf/_.pdf`` instead.
diff --git a/doc/manual/explanation/decisions/index.rst b/doc/manual/explanation/decisions/index.rst
index 817100b..0a26d7f 100644
--- a/doc/manual/explanation/decisions/index.rst
+++ b/doc/manual/explanation/decisions/index.rst
@@ -31,3 +31,4 @@ repeating it.
0010-payload-split-by-tool
0011-documentation-structure
0012-relative-cross-document-links
+ 0013-linked-pdf-set
diff --git a/doc/manual/explanation/deploy-layout.rst b/doc/manual/explanation/deploy-layout.rst
index 3f11af6..31ca5c5 100644
--- a/doc/manual/explanation/deploy-layout.rst
+++ b/doc/manual/explanation/deploy-layout.rst
@@ -11,15 +11,18 @@ organised by builder:
manual/
api/
widget/ <- Doxygen documents are peers here
- pdf/ <- the latex builder's folder, named for what it delivers
- handbook/
+ pdf/ <- the PDF set: one _.pdf per latex document
+ Handbook_1.4.pdf
xml/ <- Doxygen XML: machine input, never published
widget/
Publishing is a directory sync of ``deploy/html/``, and optionally
``deploy/pdf/``. Nothing else needs selecting, excluding or post-processing —
which is the whole reason for this shape. See
-:doc:`decisions/0006-deploy-tree-by-builder`.
+:doc:`decisions/0006-deploy-tree-by-builder`. The PDFs of ``deploy/pdf`` link
+to each other by their file names, so they are distributed as one folder
+(:doc:`decisions/0013-linked-pdf-set`); the LaTeX build directory of a
+document is a work area at ``//latex``.
What is servable, and what is not
---------------------------------
diff --git a/doc/manual/howto/publish-a-deploy-tree.rst b/doc/manual/howto/publish-a-deploy-tree.rst
index bec33f1..93534ec 100644
--- a/doc/manual/howto/publish-a-deploy-tree.rst
+++ b/doc/manual/howto/publish-a-deploy-tree.rst
@@ -17,8 +17,8 @@ Sync ``deploy/html/`` specifically, not ``deploy/`` as a whole: ``deploy/xml/``
is Doxygen's machine-readable XML, generated only when a registry opts into it
and never meant to be public
(:doc:`../explanation/decisions/0007-engine-managed-doxygen-xml`); a
-``deploy/pdf/`` folder, if any document declares the ``latex`` builder, is a
-separate artifact you distribute on its own terms, covered below.
+``deploy/pdf/`` folder, if any document declares the ``latex`` builder, is the
+PDF set, a separate artifact you distribute on its own terms, covered below.
Serve it from anywhere
------------------------
@@ -33,18 +33,40 @@ with ``doc-check`` (:doc:`../reference/cli`) rather than by clicking around.
Distributing a PDF
---------------------
-A document built with the ``latex`` builder lands at
-``deploy/pdf//.tex`` and its ``latexmk`` output alongside
-it, including the final PDF. A PDF is the one artifact that leaves the deploy
-tree entirely: its ``base_url`` is fixed at build time and travels with the
-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
+The PDF of each document with the ``latex`` builder lands in the PDF set,
+``deploy/pdf``, as ``_.pdf``: the registry ``title:`` with
+white space as ``-``, and the version the document shows. For example::
+
+ deploy/pdf/
+ Requirement-Specification_1.2.pdf
+ Test-Specification_1.2.pdf
+ Test-Report_1.2.pdf
+
+The LaTeX build directory (the .tex, the auxiliary files) is a work area at
+``//latex`` and is not part of the deploy tree.
+
+The PDFs of the set link to each other: a link to a need or a page of another
+document of the set opens that document's PDF at that place
+(:doc:`../explanation/decisions/0013-linked-pdf-set`). So the set is one
+deliverable:
+
+- Distribute the whole folder, for example as one archive, and keep the file
+ names. A renamed file breaks the links into it.
+- Build and release the set together. The file names carry the versions, so a
+ new version of one document needs new PDFs of the documents that link to it.
+- ``doc-check`` reads the PDFs and fails on a link into the set whose file or
+ place does not exist.
+
+A PDF is the one artifact that leaves the deploy tree entirely: its links to
+documents outside the set (such as the Doxygen documents) use the ``base_url``,
+fixed at build time. 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 documents outside the set 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,
@@ -55,6 +77,7 @@ and warns once per document::
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
+So a PDF of a development build has no dead links, only fewer links. The
+links within the set do not depend on the ``base_url``. 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/doc/manual/reference/cli.rst b/doc/manual/reference/cli.rst
index 0411fb9..b72d1ff 100644
--- a/doc/manual/reference/cli.rst
+++ b/doc/manual/reference/cli.rst
@@ -81,8 +81,8 @@ runs it) and as the standalone ``doc-check`` target, for re-checking an
existing deploy tree without rebuilding it. Source:
:external+zdocs-api:py:mod:`doccheck`.
-Two checks, neither of which a green ``sphinx-build``/``doxygen`` log catches on
-its own. Both read the built deploy tree, so ``--deploy`` is required — there
+Three checks, none of which a green ``sphinx-build``/``doxygen`` log catches on
+its own. All read the built deploy tree, so ``--deploy`` is required — there
is no sources-only mode, because a run that checks nothing must not be able to
report success.
@@ -101,6 +101,13 @@ B. **Dead deploy links** — scans every rendered
deploy tree entirely (external links, ``mailto:``, and similar) — policing
those is not this check's job.
+C. **Dead links between the PDFs** — reads each PDF of the PDF set
+ (``deploy/pdf``) and flags a link into another PDF whose file is not in
+ the set or has no such named destination
+ (:doc:`../explanation/decisions/0013-linked-pdf-set`). Needs ``pypdf``
+ (``sphinx/requirements-doc.txt``); without it, a set with PDFs is a
+ finding. Without PDFs, the check has nothing to read.
+
Exit codes: **0** clean, **1** one or more findings (printed to stderr, grouped
by check, with a final ``doccheck: FAILED with N finding(s)`` line), **2** bad
invocation (registry or deploy path does not exist). A clean run with no
diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst
index fa34ec6..92686ff 100644
--- a/doc/manual/reference/registry-schema.rst
+++ b/doc/manual/reference/registry-schema.rst
@@ -140,7 +140,10 @@ Each entry in ``documents:``, keyed by its id:
``title``
Shown in navigation and as the Sphinx project title (``html_title``). Falls
- back to the bare id if omitted.
+ back to the bare id if omitted. For a document with the ``latex`` builder,
+ it also names the PDF in the PDF set: ``_.pdf``, white space
+ as ``-`` (:doc:`../explanation/decisions/0013-linked-pdf-set`). Two such
+ documents whose titles give the same name are a configure-time error.
``prefix``
The document's cross-reference :term:`prefix`. Defaults to the id with
diff --git a/scripts/doccheck.py b/scripts/doccheck.py
index cad4f87..8e4757e 100755
--- a/scripts/doccheck.py
+++ b/scripts/doccheck.py
@@ -15,7 +15,13 @@
B. A link into the deploy tree points at a file that is not there.
-Both need a built deploy tree, so `--deploy` is required. There is no
+And one for the PDF set (deploy/pdf, one _.pdf per document):
+
+ C. A link from one PDF into another names a file that is not in the set,
+ or a destination that the file does not have. A PDF is mailed and filed
+ as it is, so such a link stays dead; it needs pypdf to read the PDFs.
+
+All need a built deploy tree, so `--deploy` is required. There is no
sources-only mode: a run that checks nothing must not be able to print OK.
A third check was removed rather than fixed. It scanned document sources for
@@ -171,6 +177,58 @@ def check_xref_smoketest(deploy: Path, smoke_page: str) -> list[str]:
_DEPLOY_LINK_SCAN_EXCLUDED = {"doxygen_crawl.html"}
+def _pdf_links(reader):
+ """(target file, destination) of each link of a PDF into another PDF: a
+ "go to remote" action, or a URI ``.pdf#[nameddest=]``."""
+ from urllib.parse import unquote
+
+ for page in reader.pages:
+ for annot in page.get("/Annots") or []:
+ annot = annot.get_object()
+ action = annot.get("/A")
+ action = action.get_object() if action is not None else None
+ if annot.get("/Subtype") != "/Link" or action is None:
+ continue
+ if action.get("/S") == "/GoToR":
+ target = action.get("/F")
+ target = target.get_object() if hasattr(target, "get_object") else target
+ if isinstance(target, dict):
+ target = target.get("/UF") or target.get("/F")
+ yield str(target), str(action.get("/D"))
+ elif action.get("/S") == "/URI":
+ path, _, frag = str(action["/URI"]).partition("#")
+ if path.endswith(".pdf") and "://" not in path:
+ yield path, unquote(frag).removeprefix("nameddest=")
+
+
+def check_pdf_set(deploy: Path) -> list[str]:
+ """C. Links between the PDFs of deploy/pdf that have no target."""
+ pdfs = sorted((deploy / "pdf").glob("*.pdf"))
+ if not pdfs:
+ return []
+ try:
+ import pypdf
+ except ImportError:
+ return ["pdf: cannot check the links between the PDFs: pypdf is not installed"]
+ readers = {pdf.name: pypdf.PdfReader(pdf) for pdf in pdfs}
+ dests = {name: set(reader.named_destinations) for name, reader in readers.items()}
+ findings = []
+ seen = set()
+ for name, reader in readers.items():
+ for target, dest in _pdf_links(reader):
+ if target not in readers:
+ reason = "no such PDF in deploy/pdf"
+ elif dest not in dests[target]:
+ reason = "no such destination"
+ else:
+ continue
+ finding = f"pdf/{name}: dead link -> {target}#{dest} ({reason})"
+ if finding not in seen:
+ seen.add(finding)
+ findings.append(finding)
+ return findings
+
+
def check_deploy_links(deploy: Path, base_url: str) -> list[str]:
"""C. Deploy-internal links that point at a missing file."""
findings = []
@@ -256,6 +314,7 @@ def main() -> int:
("unresolved cross-references", check_xref_smoketest(args.deploy, smoke_page))
)
groups.append(("dead deploy links", check_deploy_links(args.deploy, base_url)))
+ groups.append(("dead links between the PDFs", check_pdf_set(args.deploy)))
total = 0
matched: set[str] = set()
diff --git a/scripts/docrefs.py b/scripts/docrefs.py
index 1045e42..3016b6c 100644
--- a/scripts/docrefs.py
+++ b/scripts/docrefs.py
@@ -38,8 +38,10 @@
import os
import posixpath
import re
+import shutil
import subprocess
import sys
+import unicodedata
from pathlib import Path
from urllib.error import URLError
from urllib.parse import urlsplit, urlunsplit
@@ -413,6 +415,22 @@ def _validate(data):
f"point spec: at a document that already has it"
)
+ # -- The PDF set: one file name per document ------------------------------
+ #
+ # The PDFs go into one folder as _.pdf (pdf_file_name). Two
+ # documents with the same title part would share a name, and one PDF would
+ # replace the other.
+ by_title = {}
+ for doc_id, meta in data.get("documents", {}).items():
+ if meta.get("kind", "sphinx") == "sphinx" and "latex" in meta.get("builders", []):
+ by_title.setdefault(pdf_title_part(meta.get("title") or doc_id), []).append(doc_id)
+ clash = {part: ids for part, ids in by_title.items() if len(ids) > 1}
+ if clash:
+ raise ValueError(
+ f"docrefs: documents with a 'latex' builder have the same title in "
+ f"their PDF file name ({clash}) — give each its own 'title:'"
+ )
+
def _groups(data):
"""Ordered ``[{id, title, mode, display}]`` from the registry ``groups:`` list."""
@@ -478,7 +496,13 @@ def build_root():
both nest the same two path segments (a builder name and a doc id, in
either order) between ``deploy`` and the root. Useful for locating
sibling deploy artifacts (e.g. a Doxygen project's XML for Breathe).
+
+ ``ZDOCS_DOC_BUILD_DIR`` (set by cmake/sphinx.cmake) wins: the LaTeX build
+ writes outside ``deploy/`` (its PDF goes into ``deploy/pdf`` afterwards),
+ so its ``OUTPUT_DIR`` does not have this shape.
"""
+ if os.environ.get("ZDOCS_DOC_BUILD_DIR"):
+ return Path(os.environ["ZDOCS_DOC_BUILD_DIR"]).resolve()
return Path(os.environ["OUTPUT_DIR"]).resolve().parents[2]
@@ -489,7 +513,10 @@ def _this_doc():
(``deploy//html``) to being ``OUTPUT_DIR``'s OWN name
(``deploy/html/``) — the builder folder and the doc id swapped
places, so the doc id is now the last path segment, not the second-to-last.
+ ``ZDOCS_DOC_ID`` (set by cmake/sphinx.cmake) wins, as in `build_root`.
"""
+ if os.environ.get("ZDOCS_DOC_ID"):
+ return os.environ["ZDOCS_DOC_ID"]
return Path(os.environ["OUTPUT_DIR"]).resolve().name
@@ -655,6 +682,79 @@ def resolve_version(*, scope=None, project=None, repo_root=None, west=None):
return _fallback_version(repo_root)
+def _file_part(text, keep):
+ """``text`` as a part of a file name: ASCII letters, digits, ``-`` and the
+ characters in ``keep``; white space and ``/`` become ``-``."""
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
+ text = re.sub(r"[\s/]+", "-", text.strip())
+ text = re.sub(rf"[^A-Za-z0-9{re.escape(keep)}-]", "", text)
+ return re.sub(r"-{2,}", "-", text).strip("-")
+
+
+def pdf_title_part(title):
+ """The title part of a PDF's file name: ``Requirement Specification`` ->
+ ``Requirement-Specification``. It has no ``_``, so ``_`` starts
+ the names of this document's PDFs only."""
+ return _file_part(title, ".")
+
+
+def pdf_file_name(title, version):
+ """The file name of a document's PDF in the PDF set: ``_.pdf``,
+ e.g. ``Requirement-Specification_1.2.pdf``."""
+ return f"{pdf_title_part(title)}_{_file_part(version, '.+')}.pdf"
+
+
+def pdf_files(documents, repo_root=None):
+ """``{doc id: PDF file name}`` of each document of the set with a ``latex`` builder.
+
+ The version is the one the document shows (`resolve_version`, its
+ ``version_scope`` in ``repo_root``), so a document's PDF and the links of
+ its peers into it name the same file. `_validate` rejects two documents
+ with the same title part.
+ """
+ files = {}
+ for doc_id, meta in documents.items():
+ if meta.get("kind", "sphinx") != "sphinx" or "latex" not in meta.get("builders", []):
+ continue
+ version = resolve_version(scope=meta.get("version_scope"), repo_root=repo_root)
+ files[doc_id] = pdf_file_name(meta.get("title") or doc_id, version)
+ return files
+
+
+def _pdf_meta(doc_id, registry):
+ """The registry entry of ``doc_id`` as `pdf_files` reads it; without a
+ registry (a standalone add_sphinx_target), the doc id is the title."""
+ meta = dict(_registry(registry)["documents"][doc_id]) if registry else {"title": doc_id}
+ meta.setdefault("builders", ["latex"])
+ if "latex" not in meta["builders"]:
+ meta["builders"] = [*meta["builders"], "latex"]
+ return meta
+
+
+def remove_pdfs(doc_id, pdf_dir, registry=None, keep=None):
+ """Remove the PDFs of ``doc_id`` from ``pdf_dir``, but not ``keep`` (a file name)."""
+ meta = _pdf_meta(doc_id, registry)
+ prefix = f"{pdf_title_part(meta.get('title') or doc_id)}_"
+ for old in Path(pdf_dir).glob(f"{prefix}*.pdf"):
+ if old.name != keep:
+ old.unlink()
+
+
+def deliver_pdf(doc_id, pdf, pdf_dir, registry=None, repo_root=None):
+ """Copy ``pdf`` (latexmk's output) into ``pdf_dir`` as this document's set file.
+
+ Other versions of this document's PDF in ``pdf_dir`` are removed: the set
+ holds one version of each document. Returns the path of the copy.
+ """
+ name = pdf_files({doc_id: _pdf_meta(doc_id, registry)}, repo_root)[doc_id]
+ pdf_dir = Path(pdf_dir)
+ pdf_dir.mkdir(parents=True, exist_ok=True)
+ remove_pdfs(doc_id, pdf_dir, registry, keep=name)
+ target = pdf_dir / name
+ shutil.copyfile(pdf, target)
+ return target
+
+
class Refs:
"""Derived cross-document link structures for the current document."""
@@ -671,6 +771,7 @@ def __init__(
testmodule=None,
symbol_needs=None,
doxygen_docs=None,
+ pdf_peers=None,
):
self.reference_groups = reference_groups
self.intersphinx_mapping = intersphinx_mapping
@@ -699,6 +800,10 @@ def __init__(
#: extension that a ``conf.py`` points at a Doxygen document by id
#: (``testcoverage_source_docs``).
self.doxygen_docs = doxygen_docs or {}
+ #: ``{peer URL: PDF file name}`` of the peers in the PDF set, for a
+ #: build outside ``deploy/html`` (empty in HTML). ``pdf_links`` turns
+ #: a link into such a peer into a link to its PDF.
+ self.pdf_peers = pdf_peers or {}
def _resolve_external_url(url, external_base_url):
@@ -868,6 +973,18 @@ def _peer_url(doc_id, meta):
# ``../`` 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}
+ # The PDF set (deploy/pdf): the peers with a PDF, by the URL by which this
+ # output links them. A PDF links such a peer's PDF instead (pdf_links).
+ # Not in HTML: each entry costs a `git describe`.
+ pdf_peers = {}
+ if not relative_links:
+ pdf_set = pdf_files(documents, os.environ.get("ZDOCS_PROJECT_BASE") or None)
+ pdf_peers = {
+ _peer_url(doc_id, documents[doc_id]): name
+ for doc_id, name in pdf_set.items()
+ if doc_id != this_doc
+ }
+
# 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 ->
@@ -1032,6 +1149,7 @@ def _nav_href(doc_id, meta):
testmodule=testmodule,
symbol_needs=symbol_needs,
doxygen_docs=doxygen_docs,
+ pdf_peers=pdf_peers,
)
@@ -1448,6 +1566,29 @@ def _cli(argv=None):
help="path to the document registry (documents.yaml)",
)
+ p_pdf = sub.add_parser(
+ "pdf-deliver",
+ help="copy a document's PDF into the PDF set as _.pdf",
+ description="Copy latexmk's PDF of a document into the PDF set folder "
+ "(deploy/pdf) under the name its peers link to, _.pdf, "
+ "and remove the other versions of this document's PDF there.",
+ )
+ p_pdf.add_argument("doc_id", help="registry id of the document")
+ p_pdf.add_argument("pdf", help="the PDF that latexmk wrote")
+ p_pdf.add_argument("pdf_dir", help="the PDF set folder, e.g. /deploy/pdf")
+ p_pdf.add_argument("--registry", metavar="documents.yaml", default=None)
+ p_pdf.add_argument(
+ "--repo-root", metavar="DIR", default=None,
+ help="the repository whose tags give the version (ZDOCS_PROJECT_BASE)",
+ )
+
+ p_pdf_rm = sub.add_parser(
+ "pdf-remove", help="remove a document's PDFs from the PDF set folder"
+ )
+ p_pdf_rm.add_argument("doc_id", help="registry id of the document")
+ p_pdf_rm.add_argument("pdf_dir", help="the PDF set folder, e.g. /deploy/pdf")
+ p_pdf_rm.add_argument("--registry", metavar="documents.yaml", default=None)
+
p_version = sub.add_parser(
"version",
help="print a document's git-derived version string",
@@ -1490,6 +1631,13 @@ def _cli(argv=None):
needs_tag(args.doc_id, args.deploy_dir, args.registry)
except ValueError as exc:
sys.exit(str(exc))
+ elif args.command == "pdf-deliver":
+ target = deliver_pdf(
+ args.doc_id, args.pdf, args.pdf_dir, args.registry, args.repo_root or None
+ )
+ sys.stdout.write(f"{target}\n")
+ elif args.command == "pdf-remove":
+ remove_pdfs(args.doc_id, args.pdf_dir, args.registry or None)
elif args.command == "version":
meta = _registry(args.registry)["documents"].get(args.doc_id, {})
ver = resolve_version(
diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py
index f35e100..01b2321 100644
--- a/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py
+++ b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py
@@ -7,3 +7,7 @@
master_doc = "index"
exclude_patterns = ["_build"]
latex_documents = [("index", "pdflinks.tex", "PDF links", "zdocs", "howto")]
+# What zdocs_conf sets from the registry: the peers in the PDF set.
+zdocs_pdf_peers = {
+ "https://docs.example.org/html/requirement-specification": "Requirement-Specification_1.2.pdf"
+}
diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst
index b7a1f78..3c99ece 100644
--- a/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst
+++ b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst
@@ -9,4 +9,6 @@ A relative page: `listing <../../dox-api/main_8c_source.html#l00061>`_.
A published site: `site `_.
+A peer in the PDF set: `SD-REQ-001 `_.
+
Inside the document: :ref:`the target `.
diff --git a/sphinx/_extensions/_tests/test_doccheck_pdf_set.py b/sphinx/_extensions/_tests/test_doccheck_pdf_set.py
new file mode 100644
index 0000000..242343f
--- /dev/null
+++ b/sphinx/_extensions/_tests/test_doccheck_pdf_set.py
@@ -0,0 +1,78 @@
+# Copyright (c) 2026 inovex GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""doccheck: a link from one PDF of deploy/pdf into another must have a target."""
+
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+DOCCHECK = Path(__file__).resolve().parents[3] / "scripts" / "doccheck.py"
+
+
+def _pdf(path, dests=(), links=()):
+ """A one-page PDF with named destinations and GoToR links (file, dest)."""
+ pypdf = pytest.importorskip("pypdf")
+ from pypdf.generic import (
+ ArrayObject,
+ DictionaryObject,
+ FloatObject,
+ NameObject,
+ TextStringObject,
+ )
+
+ writer = pypdf.PdfWriter()
+ writer.add_blank_page(200, 200)
+ for dest in dests:
+ writer.add_named_destination(dest, 0)
+ annots = ArrayObject()
+ for target, dest in links:
+ annots.append(writer._add_object(DictionaryObject({
+ NameObject("/Type"): NameObject("/Annot"),
+ NameObject("/Subtype"): NameObject("/Link"),
+ NameObject("/Rect"): ArrayObject([FloatObject(v) for v in (0, 0, 10, 10)]),
+ NameObject("/A"): DictionaryObject({
+ NameObject("/S"): NameObject("/GoToR"),
+ NameObject("/F"): TextStringObject(target),
+ NameObject("/D"): TextStringObject(dest),
+ }),
+ })))
+ writer.pages[0][NameObject("/Annots")] = annots
+ with open(path, "wb") as f:
+ writer.write(f)
+
+
+def _doccheck(tmp_path, deploy):
+ registry = tmp_path / "documents.yaml"
+ registry.write_text('base_url: "http://localhost:8000/"\n')
+ return subprocess.run(
+ [sys.executable, DOCCHECK, "--registry", registry, "--deploy", deploy],
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_doccheck_follows_the_links_between_the_pdfs(tmp_path):
+ pdf_dir = tmp_path / "deploy" / "pdf"
+ pdf_dir.mkdir(parents=True)
+ _pdf(pdf_dir / "Requirement-Specification_1.2.pdf", dests=["detailed:SD-REQ-001"])
+ _pdf(
+ pdf_dir / "Test-Report_1.2.pdf",
+ links=[
+ ("Requirement-Specification_1.2.pdf", "detailed:SD-REQ-001"),
+ ("Requirement-Specification_1.2.pdf", "detailed:SD-REQ-999"),
+ ("Test-Specification_1.2.pdf", "spec:TC_1"),
+ ],
+ )
+ result = _doccheck(tmp_path, tmp_path / "deploy")
+ assert result.returncode == 1
+ assert "dead links between the PDFs (2)" in result.stderr
+ assert (
+ "pdf/Test-Report_1.2.pdf: dead link -> Requirement-Specification_1.2.pdf"
+ "#detailed:SD-REQ-999 (no such destination)" in result.stderr
+ )
+ assert "Test-Specification_1.2.pdf#spec:TC_1 (no such PDF in deploy/pdf)" in result.stderr
+ assert "SD-REQ-001 (" not in result.stderr
diff --git a/sphinx/_extensions/_tests/test_pdf_links.py b/sphinx/_extensions/_tests/test_pdf_links.py
index 4b32ea2..b42156b 100644
--- a/sphinx/_extensions/_tests/test_pdf_links.py
+++ b/sphinx/_extensions/_tests/test_pdf_links.py
@@ -7,7 +7,7 @@
from pathlib import Path
import pytest
-from pdf_links import dead_in_pdf
+from pdf_links import dead_in_pdf, pdf_target
_ROOT = str(Path(__file__).parent / "roots" / "test-pdflinks")
@@ -35,6 +35,46 @@ def test_dead_in_pdf(uri, dead):
assert dead_in_pdf(uri) is dead
+PEERS = {
+ "https://docs.example.org/html/requirement-specification": "Requirement-Specification_1.2.pdf"
+}
+
+
+@pytest.mark.parametrize(
+ "uri, target",
+ [
+ (
+ "https://docs.example.org/html/requirement-specification/detailed.html#SD-REQ-001",
+ "Requirement-Specification_1.2.pdf#detailed:SD-REQ-001",
+ ),
+ (
+ "https://docs.example.org/html/requirement-specification/safe_data/x.html#a-b",
+ "Requirement-Specification_1.2.pdf#safe_data/x:a-b",
+ ),
+ (
+ "https://docs.example.org/html/requirement-specification/detailed.html",
+ "Requirement-Specification_1.2.pdf#detailed::doc",
+ ),
+ (
+ "https://docs.example.org/html/requirement-specification/",
+ "Requirement-Specification_1.2.pdf#index::doc",
+ ),
+ ("https://docs.example.org/html/requirement-specification-old/x.html#A", None),
+ ("https://docs.example.org/html/test-report/index.html#TR-1", None),
+ ],
+)
+def test_pdf_target(uri, target):
+ assert pdf_target(uri, PEERS) == target
+
+
+@pytest.mark.sphinx("latex", srcdir=_ROOT)
+def test_a_link_to_a_peer_in_the_set_goes_to_its_pdf(app):
+ app.build()
+ tex = (Path(app.outdir) / "pdflinks.tex").read_text()
+ assert "\\sphinxhref{Requirement-Specification\\_1.2.pdf\\#detailed:SD-REQ-001}{" in tex
+ assert "\\PassOptionsToPackage{destlabel}{hyperref}" not in tex # zdocs_conf sets it
+
+
@pytest.mark.sphinx("latex", srcdir=_ROOT)
def test_a_pdf_has_no_dead_link(app):
app.build()
@@ -63,7 +103,8 @@ def test_a_link_marked_internal_with_an_external_target_is_text_too():
)
para += nodes.reference("", "here", internal=True, refuri="%index#target")
doc += para
- _unlink(SimpleNamespace(builder=SimpleNamespace(format="latex")), doc, "index")
+ app = SimpleNamespace(builder=SimpleNamespace(format="latex"), config=SimpleNamespace())
+ _unlink(app, doc, "index")
refs = list(doc.findall(nodes.reference))
assert [r["refuri"] for r in refs] == ["%index#target"]
assert para.astext() == "TC_1here"
diff --git a/sphinx/_extensions/_tests/test_pdf_set.py b/sphinx/_extensions/_tests/test_pdf_set.py
new file mode 100644
index 0000000..c4d0984
--- /dev/null
+++ b/sphinx/_extensions/_tests/test_pdf_set.py
@@ -0,0 +1,110 @@
+# Copyright (c) 2026 inovex GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""The PDF set: deploy/pdf holds _.pdf per document, linked to each other."""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
+import docrefs # noqa: E402
+
+REGISTRY = """\
+groups:
+ - id: docs
+ title: Documents
+documents:
+ requirement-specification:
+ title: "Requirement Specification"
+ kind: sphinx
+ group: docs
+ builders: [html, latex]
+ test-report:
+ title: "Test Report"
+ kind: sphinx
+ group: docs
+ builders: [html, latex]
+ notes:
+ title: "Notes"
+ kind: sphinx
+ group: docs
+ builders: [html]
+"""
+
+
+def _registry(tmp_path, text=REGISTRY):
+ path = tmp_path / "documents.yaml"
+ path.write_text(text)
+ return path
+
+
+@pytest.mark.parametrize(
+ "title, version, name",
+ [
+ ("Requirement Specification", "1.2", "Requirement-Specification_1.2.pdf"),
+ ("Test Report", "1.2-3-gabc1234", "Test-Report_1.2-3-gabc1234.pdf"),
+ ("API Traceability", "v0.0-dev+ge63ae8c", "API-Traceability_v0.0-dev+ge63ae8c.pdf"),
+ (
+ "RC ↔ ST Interface Specification",
+ "1.0-dirty",
+ "RC-ST-Interface-Specification_1.0-dirty.pdf",
+ ),
+ ("Übersicht: Plan/Ziele", "2.0", "Ubersicht-Plan-Ziele_2.0.pdf"),
+ ("Test_Report", "rel/1.0", "TestReport_rel-1.0.pdf"),
+ ],
+)
+def test_pdf_file_name(title, version, name):
+ assert docrefs.pdf_file_name(title, version) == name
+
+
+def test_only_a_document_with_a_latex_builder_is_in_the_set(tmp_path, monkeypatch):
+ monkeypatch.setenv("VERSION", "1.2")
+ documents = docrefs._registry(_registry(tmp_path))["documents"]
+ assert docrefs.pdf_files(documents) == {
+ "requirement-specification": "Requirement-Specification_1.2.pdf",
+ "test-report": "Test-Report_1.2.pdf",
+ }
+
+
+def test_two_documents_with_one_file_name_are_an_error(tmp_path):
+ text = REGISTRY.replace('title: "Test Report"', 'title: "Requirement Specification"')
+ with pytest.raises(ValueError, match="same title in their PDF file name"):
+ docrefs.manifest(_registry(tmp_path, text))
+
+
+def test_deliver_keeps_one_version_of_a_document(tmp_path, monkeypatch):
+ registry = _registry(tmp_path)
+ pdf_dir = tmp_path / "deploy" / "pdf"
+ pdf_dir.mkdir(parents=True)
+ (pdf_dir / "Test-Report_1.1.pdf").write_bytes(b"old")
+ (pdf_dir / "Test-Report-Annex_1.1.pdf").write_bytes(b"other document")
+ latexmk_pdf = tmp_path / "test-report.pdf"
+ latexmk_pdf.write_bytes(b"new")
+ monkeypatch.setenv("VERSION", "1.2")
+ target = docrefs.deliver_pdf("test-report", latexmk_pdf, pdf_dir, registry)
+ assert target == pdf_dir / "Test-Report_1.2.pdf"
+ assert sorted(p.name for p in pdf_dir.iterdir()) == [
+ "Test-Report-Annex_1.1.pdf",
+ "Test-Report_1.2.pdf",
+ ]
+ docrefs.remove_pdfs("test-report", pdf_dir, registry)
+ assert [p.name for p in pdf_dir.iterdir()] == ["Test-Report-Annex_1.1.pdf"]
+
+
+def test_a_pdf_build_knows_the_pdfs_of_its_peers(tmp_path, monkeypatch):
+ monkeypatch.setenv("VERSION", "1.2")
+ monkeypatch.setenv("ZDOCS_DOC_BASE_URL", "https://docs.example.org/")
+ monkeypatch.setenv("ZDOCS_DOC_BUILD_DIR", str(tmp_path))
+ monkeypatch.setenv("ZDOCS_DOC_ID", "test-report")
+ monkeypatch.setenv("OUTPUT_DIR", str(tmp_path / "test-report" / "latex"))
+ refs = docrefs.load("test-report", _registry(tmp_path))
+ peer = "https://docs.example.org/html/requirement-specification"
+ assert refs.pdf_peers == {peer: "Requirement-Specification_1.2.pdf"}
+ assert docrefs.build_root() == tmp_path.resolve()
+ assert docrefs._this_doc() == "test-report"
+
+ monkeypatch.setenv("OUTPUT_DIR", str(tmp_path / "deploy" / "html" / "test-report"))
+ assert docrefs.load("test-report", _registry(tmp_path)).pdf_peers == {}
diff --git a/sphinx/_extensions/pdf_links.py b/sphinx/_extensions/pdf_links.py
index 097e26a..ad5a8d7 100644
--- a/sphinx/_extensions/pdf_links.py
+++ b/sphinx/_extensions/pdf_links.py
@@ -2,10 +2,20 @@
#
# SPDX-License-Identifier: Apache-2.0
-"""Sphinx extension: no dead external link in a PDF.
+"""Sphinx extension: the links of a PDF go into the PDF set, and none is dead.
-A PDF has no sibling tree of HTML pages. Two kinds of link from the HTML
-world are dead in it:
+**Into the PDF set.** The PDFs of a document set go into one folder,
+``deploy/pdf``, as ``_.pdf`` (``docrefs.py pdf-deliver``). A
+link to a page of a peer in the set (``/.html#``)
+becomes a link to the peer's PDF and the named destination of that label:
+``#:``, or ``#::doc`` for a page without an
+anchor. The destinations exist because zdocs_conf sets hyperref's
+``destlabel``: each LaTeX label is a destination of the same name. hyperref
+writes such a link as a "go to remote" action (GoToR), file and destination.
+``zdocs_pdf_peers`` (from docrefs) maps each peer's URL to its file.
+
+**No dead link.** 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;
@@ -22,7 +32,7 @@
"""
import ipaddress
-from urllib.parse import urlsplit
+from urllib.parse import unquote, urlsplit
from docutils import nodes
@@ -55,15 +65,39 @@ def dead_in_pdf(uri):
return not parts.scheme # a relative path; mailto: and the like stay
+def pdf_target(uri, peers):
+ """``uri`` as a link into the PDF set (``#:``), or
+ ``None`` if it goes to no peer in ``peers`` (``{peer URL: file}``)."""
+ for base, name in peers.items():
+ base = base.rstrip("/")
+ if uri != base and not uri.startswith(base + "/"):
+ continue
+ path, _, frag = unquote(uri[len(base):].lstrip("/")).partition("#")
+ if path.endswith(".html"):
+ page = path[: -len(".html")]
+ else:
+ page = f"{path.rstrip('/')}/index" if path else "index"
+ return f"{name}#{page}:{frag}" if frag else f"{name}#{page}::doc"
+ return None
+
+
def _unlink(app, doctree, docname):
if app.builder.format != "latex":
return
+ peers = getattr(app.config, "zdocs_pdf_peers", None) or {}
+ for ref in doctree.findall(nodes.reference):
+ target = pdf_target(ref.get("refuri", ""), peers) if not ref.get("refid") else None
+ if target:
+ ref["refuri"] = target
+ ref["classes"].append("zd-pdf-set")
# 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"))
+ if not ref.get("refid")
+ and "zd-pdf-set" not in ref["classes"]
+ and dead_in_pdf(ref.get("refuri"))
]
for ref in dead:
ref.replace_self(nodes.inline(ref.rawsource, "", *ref.children, classes=ref["classes"]))
@@ -80,5 +114,7 @@ def _unlink(app, doctree, docname):
def setup(app):
+ # {peer URL: PDF file name}, set by zdocs_conf from the registry.
+ app.add_config_value("zdocs_pdf_peers", {}, "env")
app.connect("doctree-resolved", _unlink, priority=900)
return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/sphinx/requirements-doc.txt b/sphinx/requirements-doc.txt
index 20d0f56..94fd0fb 100644
--- a/sphinx/requirements-doc.txt
+++ b/sphinx/requirements-doc.txt
@@ -33,6 +33,9 @@ sphinxcontrib-doxylink==1.13.0
# scripts/docrefs.py, scripts/doccheck.py and sphinx/_scripts/rst_builders.py
# all `import yaml` to read documents.yaml. The engine is unusable without it.
PyYAML==6.0.3
+# scripts/doccheck.py reads the PDF set (deploy/pdf) to check the links from
+# one PDF into another. Only a set with PDFs needs it.
+pypdf==6.15.0
# -- Optional builders ---------------------------------------------------------
# sphinx-autobuild backs the `-html-live` targets (see cmake/sphinx.cmake).
diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py
index 3f1a34f..15a2ee7 100644
--- a/sphinx/zdocs_conf.py
+++ b/sphinx/zdocs_conf.py
@@ -381,6 +381,11 @@ def configure(
# single-sided far more often than it is bound. openany goes
# with it: no forced recto start per top-level heading.
"classoptions": "oneside,openany",
+ # destlabel: each LaTeX label (Sphinx writes one per need,
+ # section and page, ":") is also a named
+ # destination of that name. A PDF of the set links into
+ # this one by it (pdf_links): _.pdf#:.
+ "passoptionstopackages": r"\PassOptionsToPackage{destlabel}{hyperref}",
"preamble": latex_preamble,
"maketitle": r"\sphinxmaketitle",
"printindex": "",
@@ -434,6 +439,8 @@ def configure(
# The XML and HTML of each Doxygen document, by registry id
# (testcoverage_source_docs names some of them).
namespace["zdocs_doxygen_docs"] = refs.doxygen_docs
+ # The peers in the PDF set, by URL: a PDF links their PDF (pdf_links).
+ namespace["zdocs_pdf_peers"] = refs.pdf_peers
if testmodule is not None:
namespace["testmodule_xml_dir"] = testmodule["xml_dir"]
namespace["testspec_doxygen_url"] = testmodule["doxygen_url"]