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
50 changes: 44 additions & 6 deletions cmake/sphinx.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 <title>_<version>.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 "")
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions doc/manual/explanation/decisions/0013-linked-pdf-set.rst
Original file line number Diff line number Diff line change
@@ -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/<document>/`` 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 ``<title>_<version>.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 ``<build>/<document>/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:
``<page>:<anchor>``.
- A link to a document of the set becomes a link to its PDF:
``<title>_<version>.pdf#<page>:<anchor>``, 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/<document>/<title>.pdf`` must take
``deploy/pdf/<title>_<version>.pdf`` instead.
1 change: 1 addition & 0 deletions doc/manual/explanation/decisions/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ repeating it.
0010-payload-split-by-tool
0011-documentation-structure
0012-relative-cross-document-links
0013-linked-pdf-set
9 changes: 6 additions & 3 deletions doc/manual/explanation/deploy-layout.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title>_<version>.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 ``<build>/<document>/latex``.

What is servable, and what is not
---------------------------------
Expand Down
53 changes: 38 additions & 15 deletions doc/manual/howto/publish-a-deploy-tree.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------------
Expand All @@ -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/<document>/<document>.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 ``<title>_<version>.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
``<build>/<document>/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,
Expand All @@ -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.
11 changes: 9 additions & 2 deletions doc/manual/reference/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion doc/manual/reference/registry-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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: ``<title>_<version>.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
Expand Down
61 changes: 60 additions & 1 deletion scripts/doccheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title>_<version>.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
Expand Down Expand Up @@ -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 ``<file>.pdf#[nameddest=]<dest>``."""
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 = []
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading