From a64d1ac55a0e0c218bcb6db1e1f0140ab7b7716e Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Wed, 14 Oct 2026 18:30:01 +0200 Subject: [PATCH 1/3] feat: build: a set of PDFs in deploy/pdf that link to each other A PDF's links to other documents went to the HTML site (base_url), or became text in a development build. deploy/pdf// held the whole LaTeX build directory, and the PDF had the name of the project title. Now the PDFs of a set are one deliverable that works offline: - deploy/pdf is the PDF set: one _<version>.pdf per document with a latex builder (registry title, white space as "-"; the version the document shows). `docrefs.py pdf-deliver` copies latexmk's PDF there after the build and removes the other versions of the same document; `pdf-remove` is the clean step. Two documents with the same title part are a configure-time error. - The LaTeX build directory is a work area at <build>/<doc>/latex. docrefs takes the build root and the doc id from ZDOCS_DOC_BUILD_DIR and ZDOCS_DOC_ID, as the path of OUTPUT_DIR no longer gives them. - zdocs_conf sets hyperref's destlabel: each LaTeX label (Sphinx writes one per need, section and page, "<page>:<anchor>") is a named destination of the same name. - pdf_links turns a link to a document of the set into a link to its PDF, <title>_<version>.pdf#<page>:<anchor>, which hyperref writes as a "go to remote" action. docrefs gives the map (zdocs_pdf_peers) in a non-HTML build only. Links to other documents keep the rules before. Checked on the four safety-toolbox PDFs: 687 links between them, all with a target (a spike with Evince and pdf.js before), with the published and with a development base URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- cmake/sphinx.cmake | 50 +++++- scripts/docrefs.py | 148 ++++++++++++++++++ .../_tests/roots/test-pdflinks/conf.py | 4 + .../_tests/roots/test-pdflinks/index.rst | 2 + sphinx/_extensions/_tests/test_pdf_links.py | 45 +++++- sphinx/_extensions/_tests/test_pdf_set.py | 110 +++++++++++++ sphinx/_extensions/pdf_links.py | 46 +++++- sphinx/zdocs_conf.py | 7 + 8 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 sphinx/_extensions/_tests/test_pdf_set.py 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 <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 "") @@ -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/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 <title>_<version>.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/<id>/html``) to being ``OUTPUT_DIR``'s OWN name (``deploy/html/<id>``) — 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 ``<title part>_`` 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: ``<title>_<version>.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 <title>_<version>.pdf", + description="Copy latexmk's PDF of a document into the PDF set folder " + "(deploy/pdf) under the name its peers link to, <title>_<version>.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. <build>/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. <build>/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 <https://example.org/html/peer/index.html#REQ-1>`_. +A peer in the PDF set: `SD-REQ-001 <https://docs.example.org/html/requirement-specification/detailed.html#SD-REQ-001>`_. + Inside the document: :ref:`the target <target>`. 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 <title>_<version>.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 ``<title>_<version>.pdf`` (``docrefs.py pdf-deliver``). A +link to a page of a peer in the set (``<peer URL>/<page>.html#<anchor>``) +becomes a link to the peer's PDF and the named destination of that label: +``<file>#<page>:<anchor>``, or ``<file>#<page>::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 (``<file>#<page>:<anchor>``), 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/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, "<page>:<anchor>") is also a named + # destination of that name. A PDF of the set links into + # this one by it (pdf_links): <title>_<version>.pdf#<page>:<anchor>. + "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"] From 3af6a990af8cfefa67da7245a4a92b604a2aa6cb Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 18:30:01 +0200 Subject: [PATCH 2/3] feat: doccheck: check the links between the PDFs of the set A link from one PDF of deploy/pdf into another names a file and a named destination. If either is missing, the link is dead, and a PDF is mailed and filed as it is. doccheck now reads the PDFs of deploy/pdf (check C) and fails on a link into a PDF that is not in the set, or into a destination that the PDF does not have. It reads "go to remote" actions and relative URI links to <file>.pdf#[nameddest=]<dest>. It needs pypdf, now pinned in requirements-doc.txt (6.15.0); without it, a set with PDFs is a finding. Without PDFs, the check has nothing to read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- scripts/doccheck.py | 61 ++++++++++++++- .../_tests/test_doccheck_pdf_set.py | 78 +++++++++++++++++++ sphinx/requirements-doc.txt | 3 + 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 sphinx/_extensions/_tests/test_doccheck_pdf_set.py 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 <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 @@ -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 = [] @@ -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/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/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 `<doc>-html-live` targets (see cmake/sphinx.cmake). From 33c250cb76070fe993e02b405968862e034a9a05 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 18:30:01 +0200 Subject: [PATCH 3/3] doc: the linked PDF set (ADR 0013) ADR 0013 records the decision: deploy/pdf is a set of <title>_<version>.pdf that link to each other, the LaTeX build directory is a work area, and doc-check checks the links. The how-to for publishing says how to distribute the set (one folder, file names kept, built and released together); the deploy layout, the registry `title:` and the doc-check reference follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- .../decisions/0013-linked-pdf-set.rst | 63 +++++++++++++++++++ doc/manual/explanation/decisions/index.rst | 1 + doc/manual/explanation/deploy-layout.rst | 9 ++- doc/manual/howto/publish-a-deploy-tree.rst | 53 +++++++++++----- doc/manual/reference/cli.rst | 11 +++- doc/manual/reference/registry-schema.rst | 5 +- 6 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 doc/manual/explanation/decisions/0013-linked-pdf-set.rst 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/<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. 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 <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 --------------------------------- 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/<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, @@ -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: ``<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