From bfc488bd30514d6cf81d846839ab9b6291bf2f91 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Wed, 14 Oct 2026 19:50:40 +0200 Subject: [PATCH 1/5] feat: latex: the PDF layout in zdocs.sty The LaTeX preamble was strings in zdocs_conf. It is now a style file, sphinx/_latex/zdocs.sty, that Sphinx copies next to the .tex and that the preamble loads after Sphinx's own package. zdocs_conf only sets the values of the build with \zdsetup{title, version, footer}. A consumer can give its own style file: configure(latex_style=...). The PDF loads it last, so it can set the same keys, colors and fonts again. zdocs.sty loads no package that a Sphinx xelatex build does not load. It changes the layout only through what Sphinx documents: the page styles "normal" and "plain" and \sphinxtableofcontents. Changes in the PDF: - one font family: the body text uses the sans-serif font of the headings (FreeSans), not FreeSerif; - the running header gives the title and the version, not the doc id; - the footer says "Copyright ", without "All rights reserved"; - the contents follow the title page under a section-size heading, and the text starts on the same page (no fncychap, no roman page numbers), so the page count in the footer is right. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- sphinx/_extensions/_tests/test_zdocs_latex.py | 61 +++++++ sphinx/_latex/zdocs.sty | 151 ++++++++++++++++++ sphinx/zdocs_conf.py | 106 ++++-------- 3 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 sphinx/_extensions/_tests/test_zdocs_latex.py create mode 100644 sphinx/_latex/zdocs.sty diff --git a/sphinx/_extensions/_tests/test_zdocs_latex.py b/sphinx/_extensions/_tests/test_zdocs_latex.py new file mode 100644 index 0000000..c7fa94e --- /dev/null +++ b/sphinx/_extensions/_tests/test_zdocs_latex.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""zdocs_conf: the PDF loads zdocs.sty, then a consumer's style file.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # sphinx/ +import zdocs_conf # noqa: E402 + +STY = Path(zdocs_conf.__file__).parent / "_latex" / "zdocs.sty" + + +def _configure(tmp_path, monkeypatch, **kwargs): + monkeypatch.setenv("VERSION", "1.2") + for name in ("ZDOCS_REGISTRY", "ZDOCS_PROJECT_BASE", "LATEX_DOC"): + monkeypatch.delenv(name, raising=False) + doc_dir = tmp_path / "handbook" + doc_dir.mkdir() + namespace = {} + zdocs_conf.configure(namespace, doc_dir, **kwargs) + return namespace + + +def test_the_pdf_loads_zdocs_sty_with_the_values_of_the_build(tmp_path, monkeypatch): + namespace = _configure(tmp_path, monkeypatch, project="R&D Handbook", copyright_holder="Smith & Co") + assert namespace["latex_additional_files"] == [str(STY)] + preamble = namespace["latex_elements"]["preamble"] + assert preamble.startswith("\\usepackage{zdocs}\n") + assert "title={R\\&D Handbook}" in preamble + assert "version={1.2}" in preamble + assert "footer={Copyright " in preamble and "Smith \\& Co}" in preamble + assert "All rights reserved" not in preamble + + +def test_a_consumer_style_file_is_loaded_last(tmp_path, monkeypatch): + style = tmp_path / "acme-pdf.sty" + style.write_text("\\zdsetup{footer={ACME}}\n") + namespace = _configure(tmp_path, monkeypatch, latex_style=style) + assert namespace["latex_additional_files"] == [str(STY), str(style)] + preamble = namespace["latex_elements"]["preamble"] + assert preamble.index("\\usepackage{zdocs}") < preamble.index("\\usepackage{acme-pdf}") + + +@pytest.mark.parametrize("name", ["acme.tex", "zdocs.sty"]) +def test_a_consumer_style_file_must_be_a_sty_of_its_own_name(tmp_path, monkeypatch, name): + with pytest.raises(ValueError, match="latex_style"): + _configure(tmp_path, monkeypatch, latex_style=tmp_path / name) + + +def test_zdocs_sty_defines_what_the_engine_writes(): + sty = STY.read_text() + # zdocs_conf writes \zdsetup, doc_control writes \signatureline. + for macro in ("\\newcommand{\\zdsetup}", "\\newcommand{\\signatureline}"): + assert macro in sty + for key in ("title", "version", "footer"): + assert f"\\define@key{{zd}}{{{key}}}" in sty diff --git a/sphinx/_latex/zdocs.sty b/sphinx/_latex/zdocs.sty new file mode 100644 index 0000000..e6eb5be --- /dev/null +++ b/sphinx/_latex/zdocs.sty @@ -0,0 +1,151 @@ +% Copyright (c) 2026 inovex GmbH +% +% SPDX-License-Identifier: Apache-2.0 +% +% zdocs.sty -- the look of a zdocs PDF. +% +% zdocs_conf loads this package after Sphinx's own (\usepackage{sphinx}), and +% then a consumer's style file, if the document gives one (configure(..., +% latex_style=...)). This package changes the Sphinx layout only through what +% Sphinx documents: its page styles "normal" and "plain", its macros +% \sphinxtableofcontents and \sphinxmaketitle, and \sphinxsetup. It does not +% copy or patch sphinx.sty. +% +% It loads no package that a Sphinx xelatex build does not load already, so a +% TeX installation that builds a Sphinx PDF also builds a zdocs PDF. +% +% Settings: \zdsetup{key=value, ...}. zdocs_conf sets the values that come +% from the build; a consumer's style file can set any of them again: +% +% title the running header, left (default: the document title) +% version the running header, right (default: the document version) +% footer the running footer, left (default: Copyright ) +% +% Colors: \definecolor (xcolor) again in a consumer's style file. +% +% zdRule the lines of the running header and footer +% zdMuted the text of the running header and footer + +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{zdocs}[2026/10/14 zdocs PDF layout] + +\RequirePackage{keyval} +\RequirePackage{xcolor} +\RequirePackage{fancyhdr} +\RequirePackage{lastpage} +% For tables and figures in a consumer's documents (raw LaTeX). +\RequirePackage{longtable} +\RequirePackage{array} +\RequirePackage{booktabs} +\RequirePackage{tabularx} +\RequirePackage{graphicx} + +% -- Settings ---------------------------------------------------------------- + +\def\zd@title{} +\def\zd@version{} +\def\zd@footer{} +\define@key{zd}{title}{\def\zd@title{#1}} +\define@key{zd}{version}{\def\zd@version{#1}} +\define@key{zd}{footer}{\def\zd@footer{#1}} +\newcommand{\zdsetup}[1]{\setkeys{zd}{#1}} + +\definecolor{zdRule}{gray}{0.55} +\definecolor{zdMuted}{gray}{0.30} + +% -- Fonts ------------------------------------------------------------------- +% +% One family for the text: the body uses the sans-serif font that Sphinx +% already uses for the headings and the title page (FreeSans by default under +% xelatex). The code font stays Sphinx's monospaced font. To change the fonts, +% set them in a consumer's style file with fontspec (\setsansfont, +% \setmonofont), or give Sphinx's latex_elements["fontpkg"]. + +\renewcommand{\familydefault}{\sfdefault} + +% -- Running header and footer ------------------------------------------------- +% +% A PDF leaves the deploy tree: it is printed, mailed and filed. The header +% says which document and which version a page belongs to. A printed +% controlled document that does not give its version is not a controlled +% document. +% +% Sphinx selects the page styles "normal" (body) and "plain" (first page of a +% part, contents) by name, so the package defines these two. The page count +% is the number of the last page; the title page has no number. + +\newcommand{\zd@pagestyle}{% + \fancyhf{}% + \fancyhead[L]{\zd@title}% + \fancyhead[R]{\zd@version}% + \fancyfoot[L]{\zd@footer}% + \fancyfoot[R]{\thepage/\pageref*{LastPage}}% + \renewcommand{\headrulewidth}{0.4pt}% + \renewcommand{\footrulewidth}{0.4pt}% + \def\headrule{{\color{zdRule}\hrule\@height\headrulewidth\@width\headwidth + \vskip-\headrulewidth}}% + \def\footrule{{\color{zdRule}\hrule\@width\headwidth\@height\footrulewidth}}% +} +\fancypagestyle{normal}{\zd@pagestyle} +\fancypagestyle{plain}{\zd@pagestyle} +% fancyhdr 4.1 and newer: the font of the header and footer text. +\ifdefined\fancyhfinit\fancyhfinit{\footnotesize\color{zdMuted}}\fi + +% -- Contents ------------------------------------------------------------------ +% +% Sphinx puts the contents on a page of their own, under a chapter-size +% heading (fncychap), and numbers that page in roman numerals. A controlled +% document of a few dozen pages needs neither: the contents come after the +% title page under a section-size heading, and the text follows on the same +% page. The pages are numbered 1, 2, ... from the contents on, so the page +% count in the footer is the number of pages after the title page. + +\renewcommand{\sphinxtableofcontents}{% + \begingroup + \parskip\z@skip + \sphinxtableofcontentshook + {\py@HeaderFamily\Large\contentsname\par}% + \medskip + \@starttoc{toc}% + \endgroup + \par\bigskip +} + +% -- Sections ------------------------------------------------------------------ +% +% Top-level headings are \section, not \chapter (latex_toplevel_sectioning): +% no "Chapter N" banner and no page break before each one. +% +% \thesection must then be \arabic{section}. In this document class it is +% \thechapter.\arabic{section}, and with no \chapter the chapter counter stays +% 0, so each heading has the number 0.1, 0.2, 0.3. + +\renewcommand{\thesection}{\arabic{section}} + +% -- Back matter ----------------------------------------------------------------- +% +% No index and no glossary at the end. Sphinx adds an index to a document of +% the manual class; for a controlled document it is a page of nothing, as the +% terms are in the glossary that the document includes. + +\let\printindex\relax +\let\printglossary\relax +\let\printglossaries\relax + +% -- Signature lines ----------------------------------------------------------- +% +% The doc_control extension writes the sign-off block of the PDF with +% \signatureline{}{}. The macro is always defined: an unused +% macro costs nothing, an undefined one stops xelatex ("Undefined control +% sequence"). + +\newcommand{\signatureline}[2]{% + \noindent\textbf{#1}\par + \vspace{0.35cm}% + \noindent#2\par + \vspace{0.20cm}% + \noindent\makebox[\linewidth]{\rule{0pt}{0.5pt}\hrulefill}\par + \vspace{0.85cm}% +} + +\endinput diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 15a2ee7..dd8d583 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -53,27 +53,6 @@ from doc_control import latex_escape # noqa: E402 (same) from need_layouts import links_line, need_layout # noqa: E402,F401 (for conf.py) -#: Defines the macro ``doc_control`` emits for the PDF-only sign-off block. -#: -#: The two halves live apart for a reason that is easy to get wrong: the -#: extension decides WHAT is signed (which roles, pre-filled from which -#: directive fields) and the preamble decides what a signature line LOOKS like. -#: But they are a pair, and only one of them can fail loudly — an emitted -#: ``\signatureline`` with nothing defining it stops xelatex dead ("Undefined -#: control sequence"), whereas the macro sitting unused costs nothing. So the -#: engine always ships the definition, whatever ``signature_section`` is set to. -_SIGNATURE_LINE_MACRO = r""" -\newcommand{\signatureline}[2]{% - \noindent\textbf{#1}\par - \vspace{0.35cm} - \noindent#2\par - \vspace{0.20cm} - \noindent\makebox[\linewidth]{\rule{0pt}{0.5pt}\hrulefill}\par - \vspace{0.85cm} -} -""" - - def _env_path(name): value = os.environ.get(name) return Path(value).resolve() if value else None @@ -91,12 +70,17 @@ def configure( css_files=None, templates_path=None, needs_config=None, + latex_style=None, ): """Populate a document's ``conf.py`` globals with the shared configuration. ``doc_dir`` is the document's own directory. ``project``, ``author`` and ``copyright_holder`` are the consumer's identity. + ``latex_style`` is the path of a consumer's LaTeX style file (``.sty``). + The PDF loads it after the engine's ``zdocs.sty``, so it can change the + settings, colors and fonts that ``zdocs.sty`` documents. + ``extensions`` is appended to the engine's list rather than replacing it, so a consumer can add its own without having to restate the two-stage build's requirements (and without being able to drop them by accident). @@ -212,71 +196,31 @@ def configure( # convention, which is the same convention add_sphinx_target relies on. latex_target = os.environ.get("LATEX_DOC") or f"{folder}.tex" - # The running header and footer. - # - # A PDF leaves the deploy tree — it gets printed, mailed, filed in a binder — - # so unlike an HTML page it carries no navigation and no URL to say what it - # is. This is the only thing that does, which is why the version goes in it: - # a printed controlled document that does not state its own version is not a - # controlled document. + # The look of the PDF is in zdocs.sty (_latex/), which Sphinx copies next + # to the .tex (latex_additional_files). Here only the values that come + # from the build: the running header and footer. # # Every value here is the CONSUMER's, and every one is escaped. These are the # strings most likely to contain LaTeX syntax by accident — a company called # "Smith & Co", a document id with an underscore — and unescaped they are a # compile error at the very end of a long build, not a typo on a page. - # - # sphinxlatexstylepage.sty (pulled in by \usepackage{sphinx}) defines the - # "normal" and "plain" page styles with \fancypagestyle, and the body and ToC - # pages select those BY NAME rather than a bare "fancy". Redefining those two - # names, after \usepackage{sphinx} has run, is what actually sticks; - # \pagestyle{fancy} in the preamble does not. - page_style = rf""" - \fancyhf{{}} - \fancyhead[L]{{{latex_escape(doc_id)}}} - \fancyhead[C]{{{latex_escape(project)}}} - \fancyhead[R]{{{latex_escape(version)}}} - \fancyfoot[L]{{Copyright {copyright_year} {latex_escape(holder)}. All rights reserved}} - \fancyfoot[R]{{\thepage/\pageref{{LastPage}}}} - \renewcommand{{\headrulewidth}}{{0.4pt}} - \renewcommand{{\footrulewidth}}{{0.4pt}} - """ - + latex_files = [str(ZDOCS_DOC_DIR / "_latex" / "zdocs.sty")] latex_preamble = ( - r""" -\usepackage{longtable} -\usepackage{array} -\usepackage{booktabs} -\usepackage{fancyhdr} -\usepackage{lastpage} -\usepackage{xcolor} -\usepackage{tabularx} -\usepackage{graphicx} -""" - + _SIGNATURE_LINE_MACRO - + rf""" -\fancypagestyle{{normal}}{{{page_style}}} -\fancypagestyle{{plain}}{{{page_style}}} -""" - + r""" -% Top-level headings are \section, not \chapter (see latex_toplevel_sectioning): -% no "Chapter N" banner and no forced page break before each one, which suits a -% controlled document of a few dozen pages rather than a book. -% -% \thesection must then be re-anchored to \arabic{section}. In this document -% class it is \thechapter.\arabic{section}, and with no \chapter command ever -% invoked the chapter counter is never incremented — so every heading in the -% document numbers itself 0.1, 0.2, 0.3. That renders, compiles clean, and is -% only visible once someone looks at the PDF. -\renewcommand{\thesection}{\arabic{section}} - -% No index, no glossary back-matter. Sphinx appends an index to a manual-class -% document by default; for a controlled document it is a page of nothing, since -% the terms that matter are in the glossary the document itself includes. -\let\printindex\relax -\let\printglossary\relax -\let\printglossaries\relax -""" + "\\usepackage{zdocs}\n" + "\\zdsetup{" + f"title={{{latex_escape(project)}}}, " + f"version={{{latex_escape(version)}}}, " + f"footer={{Copyright {copyright_year} {latex_escape(holder)}}}" + "}\n" ) + if latex_style: + # A consumer's style file, loaded last. LaTeX finds a package by its + # file name, next to the .tex. + latex_style = Path(latex_style) + if latex_style.suffix != ".sty" or latex_style.stem == "zdocs": + raise ValueError(f"latex_style: {latex_style} must be a .sty file not named zdocs.sty") + latex_files.append(str(latex_style.resolve())) + latex_preamble += f"\\usepackage{{{latex_style.stem}}}\n" namespace.update( { @@ -386,6 +330,9 @@ def configure( # destination of that name. A PDF of the set links into # this one by it (pdf_links): _<version>.pdf#<page>:<anchor>. "passoptionstopackages": r"\PassOptionsToPackage{destlabel}{hyperref}", + # No chapter heading style: the top level is \\section, and + # zdocs.sty gives the contents their own heading. + "fncychap": "", "preamble": latex_preamble, "maketitle": r"\sphinxmaketitle", "printindex": "", @@ -396,6 +343,7 @@ def configure( # in a zdocs set follows; a consumer that changes master_doc has to # restate this entry, which is the honest failure (a PDF built from # the wrong root would otherwise just be quietly incomplete). + "latex_additional_files": latex_files, "latex_documents": [ ("index", latex_target, project, author or project, "manual"), ], From 72e5872f73f7d10b74894709407ce0dc758a3f2c Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 19:55:49 +0200 Subject: [PATCH 2/5] feat: latex: the need cards in the PDF CSS does not reach LaTeX, so in a PDF a zdocs card was the sphinx-needs table: grey bands, the fields before the text, labels that look like the values ("Tags:detection"), and an empty line before the links. In a LaTeX build, need_cards now replaces the table of each zdocs card with a zd_need node and writes it with new macros of zdocs.sty (zdneed, \zdneedhead, \zdneedtitle, \zdneedfield): - no background, so the card prints well: a bar on the left in the accent color of the type (the same OKLCH rule as --zd-accent); - the head: type, ID and the status as a framed badge in the color of the HTML pill (good, bad, gap, none, or the type accent); - the title in bold, then the text, then the fields and links; - each field and link with a grey label in one aligned column; - a long ID also breaks after an underscore; - the card breaks across pages (framed, which Sphinx loads already). The label of the need stays at the top of the card, so links into the card, and between the PDFs of the set, keep their target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- .../_tests/roots/test-needcards-latex/conf.py | 27 ++ .../roots/test-needcards-latex/index.rst | 18 ++ sphinx/_extensions/_tests/test_need_cards.py | 57 ++++ sphinx/_extensions/need_cards.py | 254 +++++++++++++++++- sphinx/_latex/zdocs.sty | 85 ++++++ sphinx/_scripts/need_layouts.py | 4 +- sphinx/_static/zdocs-needs.css | 5 +- 7 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 sphinx/_extensions/_tests/roots/test-needcards-latex/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-needcards-latex/index.rst diff --git a/sphinx/_extensions/_tests/roots/test-needcards-latex/conf.py b/sphinx/_extensions/_tests/roots/test-needcards-latex/conf.py new file mode 100644 index 0000000..cbb9466 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards-latex/conf.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ +sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "_scripts")) + +from need_layouts import need_layout # noqa: E402 + +extensions = ["sphinx_needs", "need_cards"] +master_doc = "index" +exclude_patterns = ["_build"] +suppress_warnings = ["config.cache"] +latex_documents = [("index", "cards.tex", "Cards", "zdocs", "howto")] + +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), + dict(directive="spec", title="Test Case", prefix="SPEC_", color="", style="node"), +] +needs_fields = {"component": {}} +needs_layouts = { + "zdocs_card": need_layout(fields=[("component", "Component")]), +} +needs_default_layout = "zdocs_card" diff --git a/sphinx/_extensions/_tests/roots/test-needcards-latex/index.rst b/sphinx/_extensions/_tests/roots/test-needcards-latex/index.rst new file mode 100644 index 0000000..a180440 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards-latex/index.rst @@ -0,0 +1,18 @@ +Need cards in a PDF +=================== + +.. req:: A requirement + :id: REQ_1 + :status: approved + :component: storage + + The text of the requirement. + +.. spec:: A test case + :id: SPEC_A_LONG_ID + :status: passed + :links: REQ_1 + +.. req:: Not a card + :id: REQ_2 + :layout: clean diff --git a/sphinx/_extensions/_tests/test_need_cards.py b/sphinx/_extensions/_tests/test_need_cards.py index af9ef27..e63d0e6 100644 --- a/sphinx/_extensions/_tests/test_need_cards.py +++ b/sphinx/_extensions/_tests/test_need_cards.py @@ -6,6 +6,7 @@ from pathlib import Path +import need_cards import pytest from need_cards import ( _JS_SOURCE, @@ -95,3 +96,59 @@ def link(href_part): assert NEED_REF_CLASS in link('href="#REQ_1"') # the links field of SPEC_1 assert NEED_REF_CLASS not in link("main_8c_source.html#l00042") assert f"a.{NEED_REF_CLASS}" in _JS_SOURCE.read_text() + + +# -- The card in a PDF --------------------------------------------------------- + +_LATEX_ROOT = str(Path(__file__).parent / "roots" / "test-needcards-latex") + + +@pytest.mark.parametrize( + "color, rgb", + [ + ("#FDEBD0", "8B6001"), # the type color of a requirement: brown-orange + ("#D6EAF8", "0571A3"), + ("#eeeeee", "696969"), # a grey stays grey + ("#fff", "696969"), + ("", None), + ("red", None), # a name: no hue that the engine can compute + ], +) +def test_the_accent_of_a_type_color(color, rgb): + assert need_cards.accent(color) == rgb + + +@pytest.mark.parametrize( + "cls, tone", + [ + ("zd-status-passed", "good"), + ("zd-status-failed", "bad"), + ("zd-verdict-partial", "gap"), + ("zd-status-skipped", "none"), + ("zd-status-approved", "accent"), + ], +) +def test_the_tone_of_a_status(cls, tone): + assert need_cards.tone(cls) == tone + + +@pytest.mark.sphinx("latex", srcdir=_LATEX_ROOT) +def test_a_card_is_written_with_the_macros_of_zdocs_sty(app): + app.build() + tex = (Path(app.outdir) / "cards.tex").read_text() + req = tex[tex.index("\\begin{zdneed}{8B6001}") :] + req = req[: req.index("\\end{zdneed}")] + assert "\\phantomsection\\label{\\detokenize{index:REQ_1}}" in req + assert "\\zdneedhead{Requirement}{REQ\\_\\allowbreak{}1}{accent}{approved}" in req + assert "\\zdneedtitle{" in req and "A requirement" in req + # The title, then the text, then the fields and links. + text = req.index("The text of the requirement.") + assert req.index("\\zdneedtitle{") < text < req.index("\\zdneedfield{Component}") + assert req.index("\\zdneedfield{Component}") < req.index("\\zdneedfield{links incoming}") + assert "SPEC\\_A\\_LONG\\_ID" in req[req.index("\\zdneedfield{links incoming}") :] + + spec = tex[tex.index("\\begin{zdneed}{666666}") :] + assert spec.index("{good}{passed}") < spec.index("\\end{zdneed}") + # A need of another layout keeps the sphinx-needs table. + assert tex.count("\\begin{zdneed}") == 2 + assert "\\label{\\detokenize{index:REQ_2}}" in tex diff --git a/sphinx/_extensions/need_cards.py b/sphinx/_extensions/need_cards.py index 6dce39a..1629cdc 100644 --- a/sphinx/_extensions/need_cards.py +++ b/sphinx/_extensions/need_cards.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -"""What the zdocs need cards (``zdocs-needs.css``) need from the build. +r"""What the zdocs need cards (``zdocs-needs.css``) need from the build. **The color of each need type.** A need type in ``needs_config.toml`` has a ``color``; sphinx-needs uses it in need flows. This extension writes @@ -26,8 +26,26 @@ to another card, a popup shows a copy of that card. The script fetches the target page in the browser, so it needs a page served over HTTP; a page opened as ``file://`` gets previews of the needs on the same page only. + +**The card in a PDF.** CSS does not reach LaTeX: there, sphinx-needs writes a +card as a table of grey rows. In a LaTeX build the extension replaces the +table of each zdocs card (layout name ``zdocs...``) with a `zd_need` node, and +writes it with the macros of ``zdocs.sty``:: + + \begin{zdneed}{<accent RRGGBB>} + \zdneedhead{<type>}{<ID>}{<tone>}{<status>} + \zdneedtitle{<title>} + <text of the need> + \zdneedfield{<label>}{<value>} (each field and link) + \end{zdneed} + +The accent is the type color of the HTML card, made darker. The tone of the +status (``good``, ``bad``, ``gap``, ``none`` or ``accent``) is the color of +the pill in ``zdocs-needs.css``. The links in the card stay links, and the +label of the need stays a LaTeX label, so links into the card still work. """ +import math import re from pathlib import Path @@ -99,6 +117,235 @@ def _mark_need_refs(table, needs): ref["classes"].append(NEED_REF_CLASS) +# -- The card in a PDF --------------------------------------------------------- + +#: The tone of a status or verdict value (its class), as zdocs-needs.css +#: colors the pill. Other values have the tone "accent", the type color. +TONES = { + "good": ("zd-status-passed", "zd-verdict-true"), + "bad": ("zd-status-failed", "zd-status-error", "zd-verdict-broken"), + "none": ( + "zd-status-skipped", "zd-status-filtered", "zd-status-blocked", "zd-status-not-run", + "zd-verdict-unattributed", "zd-verdict-unresolved", "zd-verdict-no-cov", + ), + "gap": ("zd-verdict-partial", "zd-verdict-no-impl"), +} + + +def tone(cls): + """The tone of the pill class ``cls``.""" + for name, classes in TONES.items(): + if cls in classes: + return name + return "accent" + + +def _srgb_to_linear(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def _linear_to_srgb(c): + return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055 + + +def _to_oklab(r, g, b): + """OKLab of an sRGB color (each channel 0..1).""" + r, g, b = (_srgb_to_linear(c) for c in (r, g, b)) + l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3) + m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3) + s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3) + return ( + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ) + + +def _linear_from_oklab(L, a, b): + """Linear sRGB of an OKLab color; a channel outside 0..1 is out of gamut.""" + l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3 + m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3 + s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3 + return ( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ) + + +def accent(color): + """The accent of a card of type color ``color`` (``#rrggbb``, ``#rgb``), as + ``RRGGBB``; ``None`` for no such color. + + The same as ``--zd-accent`` in zdocs-needs.css: the hue of the type color, + lightness 0.52 and four times the chroma plus 0.02 (OKLCH), so that a + pastel type color gives a line that is dark enough on paper. A chroma + that sRGB cannot show is made smaller until it fits. + """ + m = re.fullmatch(r"#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})", str(color or "").strip()) + if not m: + return None + hexa = m.group(1) + if len(hexa) == 3: + hexa = "".join(c * 2 for c in hexa) + _, a, b = _to_oklab(*(int(hexa[i : i + 2], 16) / 255 for i in (0, 2, 4))) + chroma, hue = math.hypot(a, b), math.atan2(b, a) + # A grey has no hue: it stays grey. + chroma = chroma * 4 + 0.02 if chroma > 1e-3 else 0.0 + while True: + rgb = _linear_from_oklab(0.52, chroma * math.cos(hue), chroma * math.sin(hue)) + if chroma <= 0 or all(-1e-6 <= c <= 1 + 1e-6 for c in rgb): + break + chroma = max(chroma - 0.002, 0.0) + return "".join(f"{round(_linear_to_srgb(min(max(c, 0.0), 1.0)) * 255):02X}" for c in rgb) + + +#: The accent of a type without a color: the grey of the HTML card. +DEFAULT_ACCENT = "666666" + + +class zd_need(nodes.General, nodes.Element): + """A zdocs card in a LaTeX build. Attributes: ``accent``, ``type_name``, + ``need_id``, ``tone``, ``pill``. Children: one `zd_need_title`, then the + text of the need, then one `zd_need_field` for each field and link.""" + + +class zd_need_title(nodes.Part, nodes.TextElement): + """The title of a card.""" + + +class zd_need_field(nodes.Part, nodes.TextElement): + """A field or link of a card; attribute ``label``, the value as children.""" + + +def _is_zdocs_card(table): + return "need" in table["classes"] and any( + c.startswith("needs_layout_zdocs") for c in table["classes"] + ) + + +def _row(table, cls): + for row in table.findall(nodes.row): + if cls in row["classes"]: + return row + return None + + +def _inline(row, cls): + if row is None: + return None + for node in row.findall(nodes.inline): + if cls in node["classes"]: + return node + return None + + +def _fields(row): + """``(label, value nodes)`` of each field and link line of the meta ``row``.""" + if row is None: + return + for line in row.findall(nodes.line): + if next(iter(line.findall(nodes.line, include_self=False)), None) is not None: + continue # a line that holds lines + if not line.astext().strip(): + continue + label = next((n for n in line.findall(nodes.inline) if "needs_label" in n["classes"]), None) + if label is not None: + # <inline needs_<field>><inline needs_label>Label:</inline><value></inline> + field = label.parent + value = [n.deepcopy() for n in field.children if n is not label] + yield label.astext().strip().rstrip(":").strip(), value + continue + # A link line: "refined by: " and then the links. + children = list(line.children) + if children and isinstance(children[0], nodes.Text) and ":" in children[0]: + name, _, rest = str(children[0]).partition(":") + value = ([nodes.Text(rest.lstrip())] if rest.strip() else []) + [ + n.deepcopy() for n in children[1:] + ] + yield name.strip(), value + else: + yield "", [n.deepcopy() for n in children] + + +def card_node(table, accents): + """The `zd_need` for the need ``table`` of a zdocs card; ``accents`` maps the + ``needs_type_<name>`` class to an accent ``RRGGBB``.""" + head = _row(table, "head") + type_name = _inline(head, "needs_type_name") + need_id = table["ids"][0] if table["ids"] else "" + pill_field = next((f for f in PILL_FIELDS if _inline(head, f"needs_{f}") is not None), None) + pill = _inline(head, f"needs_{pill_field}") if pill_field else None + pill_text = pill.astext().strip() if pill is not None else "" + type_class = next((c for c in table["classes"] if c.startswith("needs_type_")), "") + card = zd_need( + ids=list(table["ids"]), + accent=accents.get(type_class, DEFAULT_ACCENT), + type_name=type_name.astext().strip() if type_name is not None else "", + need_id=need_id, + pill=pill_text, + tone=tone(status_class(pill_text, pill_field)) if pill_text else "accent", + ) + title = _inline(head, "needs_title") + card += zd_need_title("", "", *([n.deepcopy() for n in title.children] if title else [])) + content = _row(table, "content") + if content is not None: + for entry in content.findall(nodes.entry): + card.extend(n.deepcopy() for n in entry.children) + break + for label, value in _fields(_row(table, "meta")): + card += zd_need_field("", "", *value, label=label) + return card + + +def _latex_cards(app, doctree, docname): + if app.builder.format != "latex": + return + from sphinx_needs.config import NeedsSphinxConfig + + accents = {} + for need_type in NeedsSphinxConfig(app.config).types: + value = accent(need_type.get("color")) + if value: + accents["needs_type_" + "".join(need_type["directive"].split())] = value + for table in list(doctree.findall(nodes.table)): + if _is_zdocs_card(table): + table.replace_self(card_node(table, accents)) + + +def visit_zd_need(self, node): + self.body.append(f"\n\\begin{{zdneed}}{{{node['accent']}}}\n") + # The target of a link to the need: at the top of the card. + self.body.append("\\phantomsection" + self.hypertarget_to(node)) + # A long ID (a test result) also breaks after an underscore. + need_id = self.encode(node["need_id"]).replace("\\_", "\\_\\allowbreak{}") + self.body.append( + f"\\zdneedhead{{{self.encode(node['type_name'])}}}" + f"{{{need_id}}}" + f"{{{node['tone']}}}{{{self.encode(node['pill'])}}}\n" + ) + + +def depart_zd_need(self, node): + self.body.append("\n\\end{zdneed}\n") + + +def visit_zd_need_title(self, node): + self.body.append("\\zdneedtitle{") + + +def depart_zd_need_title(self, node): + self.body.append("}\n") + + +def visit_zd_need_field(self, node): + self.body.append(f"\\zdneedfield{{{self.encode(node['label'])}}}{{") + + +def depart_zd_need_field(self, node): + self.body.append("}\n") + + def _write(app, exception): if exception is not None or app.builder.format != "html": return @@ -125,10 +372,15 @@ def _add_files(app): def setup(app): + app.add_node(zd_need, latex=(visit_zd_need, depart_zd_need)) + app.add_node(zd_need_title, latex=(visit_zd_need_title, depart_zd_need_title)) + app.add_node(zd_need_field, latex=(visit_zd_need_field, depart_zd_need_field)) app.connect("builder-inited", _add_files) # After sphinx-needs replaced each need node with its table and resolved # the links in it (doctree-resolved at the default priority 500). app.connect("doctree-resolved", _add_status_classes, priority=600) + # After the status classes; pdf_links (900) finds the links in the card. + app.connect("doctree-resolved", _latex_cards, priority=650) # After the HTML builder copied the static files, which it does at the end. app.connect("build-finished", _write) return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_latex/zdocs.sty b/sphinx/_latex/zdocs.sty index e6eb5be..e049188 100644 --- a/sphinx/_latex/zdocs.sty +++ b/sphinx/_latex/zdocs.sty @@ -132,6 +132,91 @@ \let\printglossary\relax \let\printglossaries\relax +% -- Need cards ------------------------------------------------------------------ +% +% The need_cards extension writes each zdocs card (need_layout()) as: +% +% \begin{zdneed}{<accent RRGGBB>} +% \zdneedhead{<type>}{<ID>}{<tone>}{<status>} +% \zdneedtitle{<title>} +% <text of the need> +% \zdneedfield{<label>}{<value>} (each field and link) +% \end{zdneed} +% +% The card has no background, so that it prints well: a bar in the accent +% color of its type on the left, the title in bold, then the text, then the +% fields and links with their labels in one column. The status is a framed +% badge in the color of its tone: good (passed), bad (failed), gap (partial), +% none (skipped, not run) or accent (any other status, in the type color). +% The card breaks across pages (framed). +% +% A consumer's style file can change the colors (\definecolor) and the +% width of the label column (\setlength{\zdneedlabelwidth}{...}), or define +% the macros again (\renewcommand, \renewenvironment). + +\RequirePackage{framed} + +\definecolor{zdLabel}{gray}{0.40} +\definecolor{zdTonegood}{HTML}{116329} +\definecolor{zdTonebad}{HTML}{A40E26} +\definecolor{zdTonegap}{HTML}{7D4E00} +\definecolor{zdTonenone}{HTML}{424A53} +\newlength{\zdneedlabelwidth} +\setlength{\zdneedlabelwidth}{7em} +\newif\ifzd@needfields + +\newenvironment{zdneed}[1]{% + \par\addvspace{1.1\baselineskip}% + \definecolor{zdAccent}{HTML}{#1}% + \colorlet{zdToneaccent}{zdAccent}% + \zd@needfieldsfalse + \def\FrameCommand{{\color{zdAccent}\vrule\@width 2pt}\hspace{0.9em}}% + \MakeFramed{\advance\hsize-\width\FrameRestore}% +}{% + \par\endMakeFramed + \par\addvspace{1.1\baselineskip}% +} + +% The status badge: text and frame in the color of the tone. +\newcommand{\zdneedbadge}[2]{% + {\color{zdTone#1}\fboxsep=1.5pt\fboxrule=0.6pt + \fbox{\footnotesize\bfseries\vphantom{Ag}\,#2\,}}% +} + +\newcommand{\zdneedhead}[4]{% + \par\noindent + {\raggedright + {\color{zdAccent}\footnotesize\bfseries #1}\hspace{0.6em}% + {\small\bfseries #2}% + \if\relax\detokenize{#4}\relax\else\hfill\zdneedbadge{#3}{#4}\fi + \par}% + \nobreak +} + +\newcommand{\zdneedtitle}[1]{% + \vspace{0.15\baselineskip}% + \noindent{\large\bfseries #1\par}% + \nobreak\vspace{0.35\baselineskip}% +} + +% A label wider than the column goes on a line of its own. +\newcommand{\zdneedfield}[2]{% + \par + \ifzd@needfields\else + \zd@needfieldstrue\addvspace{0.5\baselineskip}% + \parskip\z@skip + \fi + \sbox\z@{\footnotesize #1}% + \noindent + \ifdim\wd\z@>\dimexpr\zdneedlabelwidth-0.5em\relax + {\color{zdLabel}\usebox\z@}\par\nobreak\noindent\hspace*{\zdneedlabelwidth}% + \else + \makebox[\zdneedlabelwidth][l]{\color{zdLabel}\usebox\z@}% + \fi + \hangindent\zdneedlabelwidth\hangafter\@ne + {\small #2\par}% +} + % -- Signature lines ----------------------------------------------------------- % % The doc_control extension writes the sign-off block of the PDF with diff --git a/sphinx/_scripts/need_layouts.py b/sphinx/_scripts/need_layouts.py index 1f76f1a..0e81cbc 100644 --- a/sphinx/_scripts/need_layouts.py +++ b/sphinx/_scripts/need_layouts.py @@ -25,7 +25,9 @@ need type its ``color`` from the needs configuration. The rows stay in the sphinx-needs order (head, fields, text); the CSS shows -the fields last. So the PDF and a screen reader still give the fields first. +the fields last, so a screen reader still gives the fields first. In a PDF, +``need_cards`` writes the card with the macros of ``zdocs.sty``: head, title, +text, then the fields and links. sphinx-needs shows every incoming link of a need. A requirement can have hundreds of test results that cover it, and that list hides the rest of the diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css index d8d77f9..11465cc 100644 --- a/sphinx/_static/zdocs-needs.css +++ b/sphinx/_static/zdocs-needs.css @@ -96,8 +96,9 @@ table.need.need.need[class*=" needs_layout_zdocs"] { /* * Show the head, the text, then the fields. The rows stay in the sphinx-needs - * order (head, meta, content) in the HTML, so the PDF does not change; the - * table body becomes a flex column and the meta row moves to the end. + * order (head, meta, content) in the HTML; the table body becomes a flex + * column and the meta row moves to the end. (The PDF has its own card: + * need_cards and zdocs.sty.) */ table.need.need.need[class*=" needs_layout_zdocs"] > tbody { display: flex; From 946f13efbdc2e17eb90c5317706dce0097475067 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 20:04:45 +0200 Subject: [PATCH 3/5] feat: latex: need tables that fit the page In a PDF, a needtable without :colwidths: had the fixed widths of sphinx-needs (15 parts for the title, 5 for each other column): the need IDs broke in two, and long tags and Kconfig names ran past the edge of the page. A table of 30 rows or fewer was a tabular, which does not break across pages: a long one moved to the next page and left an empty page. The new extension pdf_tables gives each such table, in a LaTeX build: - column widths from the content: each column as wide as its longest word (a need ID stays on one line), the rest of the line to the columns with the most text; - the class longtable, so it breaks across pages; - ragged-right cells (class zd-needtable); - a smaller font and less padding (zd-table-small, zd-table-smaller) for a table with too many columns for the line; - a line break after a slash, and after a dot before a letter. zdocs.sty: in a table and in a need card, a line can break after an underscore (penalty 100, so TeX still breaks at a space if it can); in the text of a page a name stays one word. A tabulary column is at least 5.5em wide. The toolbox test report: 960 overfull lines -> 2, 50 pages -> 37. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- .../_tests/roots/test-pdftables/conf.py | 21 ++ .../_tests/roots/test-pdftables/index.rst | 23 +++ sphinx/_extensions/_tests/test_pdf_tables.py | 60 ++++++ sphinx/_extensions/pdf_tables.py | 181 ++++++++++++++++++ sphinx/_latex/zdocs.sty | 38 ++++ sphinx/zdocs_conf.py | 3 + 6 files changed, 326 insertions(+) create mode 100644 sphinx/_extensions/_tests/roots/test-pdftables/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-pdftables/index.rst create mode 100644 sphinx/_extensions/_tests/test_pdf_tables.py create mode 100644 sphinx/_extensions/pdf_tables.py diff --git a/sphinx/_extensions/_tests/roots/test-pdftables/conf.py b/sphinx/_extensions/_tests/roots/test-pdftables/conf.py new file mode 100644 index 0000000..0e3a342 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdftables/conf.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ + +extensions = ["sphinx_needs", "pdf_tables"] +master_doc = "index" +exclude_patterns = ["_build"] +suppress_warnings = ["config.cache"] +latex_documents = [("index", "tables.tex", "Tables", "zdocs", "howto")] + +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), +] +_field = {"schema": {"type": "string"}, "nullable": True} +needs_fields = {name: dict(_field) for name in ("platform", "scenario", "module", "reason")} +needs_id_regex = r"^[A-Z0-9_-]{5,}" diff --git a/sphinx/_extensions/_tests/roots/test-pdftables/index.rst b/sphinx/_extensions/_tests/roots/test-pdftables/index.rst new file mode 100644 index 0000000..0078307 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdftables/index.rst @@ -0,0 +1,23 @@ +Need tables in a PDF +==================== + +.. req:: Sealing on initialisation + :id: SD-REQ-001 + :tags: core; kconfig-SAFE_DATA_LOCK_TIMEOUT_MS + :platform: native_sim/native/64 + :scenario: safe_data.api.timeout + :module: tests/safe_data + :reason: ztest skip + +.. needtable:: + :columns: id, title, status, tags + :style: table + +.. needtable:: + :columns: id, title, status, tags, platform, scenario, module, reason + :style: table + +.. needtable:: + :columns: id, title + :colwidths: 30, 70 + :style: table diff --git a/sphinx/_extensions/_tests/test_pdf_tables.py b/sphinx/_extensions/_tests/test_pdf_tables.py new file mode 100644 index 0000000..c9f64d9 --- /dev/null +++ b/sphinx/_extensions/_tests/test_pdf_tables.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""pdf_tables: need tables in a PDF get widths from their content and break across pages.""" + +import re +from pathlib import Path + +import pytest +from docutils import nodes +from pdf_tables import _add_breaks, _words, column_widths + +_ROOT = str(Path(__file__).parent / "roots" / "test-pdftables") + + +def test_a_need_id_is_one_word_and_a_kconfig_name_breaks_after_an_underscore(): + assert list(_words("SD-REQ-001 kconfig-SAFE_DATA_STATS")) == [ + "SD-REQ-001", + "kconfig-SAFE_", + "DATA_", + "STATS", + ] + assert list(_words("native_sim/native/64 safe_data.api.timeout 0.21")) == [ + "native_", "sim/", "native/", "64", "safe_", "data.", "api.", "timeout", "0.21", + ] + + +def test_each_column_is_as_wide_as_its_longest_word(): + ids = ["ID", "SD-REQ-001", "SD-REQ-002"] + titles = ["Title", "Sealing on initialisation " * 4] + widths = column_widths([ids, titles], line=60) + assert widths[0] >= len("SD-REQ-001") # the ID does not break + assert sum(widths) == 60 # the rest of the line goes to the title + + +def test_a_short_table_keeps_the_width_of_its_text(): + assert column_widths([["ID", "A-1"], ["Status", "open"]], line=90) == [7, 9] + + +def test_a_break_after_a_slash_and_after_a_dot_before_a_letter(): + entry = nodes.entry("", nodes.paragraph("", "native_sim/native/64 0.21 a.b")) + _add_breaks(entry) + raw = [n.astext() for n in entry.findall(nodes.raw)] + assert raw == ["\\allowbreak{}"] * 3 + assert entry.astext().replace("\\allowbreak{}", "") == "native_sim/native/64 0.21 a.b" + + +@pytest.mark.sphinx("latex", srcdir=_ROOT) +def test_need_tables_in_the_pdf(app): + app.build() + tex = (Path(app.outdir) / "tables.tex").read_text() + tables = re.findall(r"\\begin\{sphinxuseclass\}\{zd-needtable\}(.*?)\\end\{(?:longtable|tabular)\}", tex, re.S) + assert len(tables) == 2 # not the table with :colwidths: + narrow, wide = tables + assert "\\begin{longtable}" in narrow + assert "\\begin{sphinxuseclass}{zd-table-small" in wide or "zd-table-smaller" in wide + assert "sim/\\allowbreak{}native/\\allowbreak{}64" in wide + # The table with :colwidths: keeps them. + assert "\\X{30}{100}\\X{70}{100}" in tex diff --git a/sphinx/_extensions/pdf_tables.py b/sphinx/_extensions/pdf_tables.py new file mode 100644 index 0000000..72be149 --- /dev/null +++ b/sphinx/_extensions/pdf_tables.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Need tables in a PDF: column widths from the content, and page breaks. + +sphinx-needs gives a ``needtable`` without ``:colwidths:`` fixed widths: 15 +parts for the title, 5 for each other column. In a PDF, a column of long +values (tags, Kconfig names) then runs past the edge of the page, and a +column of short ones (need IDs) is so narrow that each ID breaks in two. And +Sphinx writes a table of 30 rows or fewer as a ``tabular``, which does not +break across pages: a long table moves to the next page and leaves an empty +page before it. + +In a LaTeX build, this extension gives each such table: + +- column widths from its content (`column_widths`): each column is as wide as + its longest word, and the rest of the line goes to the columns with the + most text; +- the class ``longtable``, so that it breaks across pages and repeats its + header row; +- the container class ``zd-needtable``: text not justified, which zdocs.sty + sets (Sphinx's ``sphinxclass<name>`` environment); +- if it is too wide for the line, a smaller font and less cell padding: the + class ``zd-table-small`` or ``zd-table-smaller`` too; +- a possible line break after each ``/``, and after each ``.`` before a + letter, in its text: ``native_sim/native/64``, ``safe_data.api.timeout``. + (After an underscore, zdocs.sty allows a break everywhere.) + +A ``needtable`` with ``:colwidths:`` keeps its widths. +""" + +import re + +from docutils import nodes + +#: The width of a line of a table, in characters of the table font. Only the +#: ratio of the widths is in the PDF (\X{w}{sum}), so this is the scale on +#: which a column is "as wide as its longest word". +LINE = 90 + +#: The classes for a table that is too wide, and the line width in their +#: font: \footnotesize and \scriptsize, with less padding (zdocs.sty). +SMALLER = (("zd-table-small", 108), ("zd-table-smaller", 124)) + +#: A word longer than this breaks (after an underscore, zdocs.sty) and does +#: not make its column wider. +LONGEST_WORD = 24 + +#: The padding of a cell, in characters (Sphinx: 2 \\tabcolsep). +PADDING = 3 + + +def _length(text): + """The width of ``text`` in characters: a capital letter or a digit is + wider than a small letter (need IDs are capitals and digits).""" + return sum(1.3 if c.isupper() or c.isdigit() else 1.0 for c in text) + + +#: Where a line can break inside a word: after an underscore (zdocs.sty), +#: after a slash, and after a dot before a letter (`_add_breaks`). TeX also +#: breaks after a hyphen, but a need ID (SD-REQ-001) should stay on one line, +#: so a column is as wide as its longest ID. +_BREAK = re.compile(r"(?<=[_/])|(?<=\.)(?=[A-Za-z])") + +#: Where `_add_breaks` adds a break: after a slash, and after a dot before a letter. +_ADD_BREAK = re.compile(r"(?<=/)(?=.)|(?<=\.)(?=[A-Za-z])") + + +def _words(text): + """The parts of ``text`` that a line does not break.""" + for word in text.split(): + yield from (part for part in _BREAK.split(word) if part) + + +def _add_breaks(entry): + """A LaTeX ``\\allowbreak`` at each `_ADD_BREAK` in the text of ``entry``.""" + for text in list(entry.findall(nodes.Text)): + parts = _ADD_BREAK.split(str(text)) + if len(parts) < 2: + continue + new = [] + for part in parts: + if new: + new.append(nodes.raw("", "\\allowbreak{}", format="latex")) + new.append(nodes.Text(part)) + parent = text.parent + index = parent.index(text) + parent[index : index + 1] = new + + +def _least(cells): + longest = max((_length(w) for c in cells for w in _words(c)), default=1) + return min(longest, LONGEST_WORD) + PADDING + + +def _least_width(columns): + """The width of a table whose columns are as wide as their longest word.""" + return sum(_least(cells) for cells in columns) + + +def column_widths(columns, line=LINE): + """The widths of ``columns`` (each a list of cell texts, the header too).""" + least = [_least(cells) for cells in columns] + most = [ + max(max((_length(c) for c in cells), default=1) + PADDING, low) + for cells, low in zip(columns, least) + ] + free = line - sum(least) + want = [m - l for m, l in zip(most, least)] + if free <= 0 or sum(want) == 0: + widths = least + elif sum(want) <= free: + widths = most + else: + widths = [l + free * w / sum(want) for l, w in zip(least, want)] + return [max(1, round(w)) for w in widths] + + +#: The key on the doctree: the IDs of the tables of the needtables with +#: ``:colwidths:``. sphinx-needs drops the option when it builds the table. +_GIVEN = "zdocs_needtable_widths_given" + + +def _note_given_widths(app, doctree, docname): + if app.builder.format != "latex": + return + from sphinx_needs.directives.needtable import Needtable + + doctree[_GIVEN] = { + node["ids"][0] + "-table_node" + for node in doctree.findall(Needtable) + if node.get("colwidths") and node["ids"] + } + + +def _columns(table): + tgroup = next(iter(table.findall(nodes.tgroup))) + columns = [[] for _ in tgroup.findall(nodes.colspec)] + for row in tgroup.findall(nodes.row): + entries = [c for c in row.children if isinstance(c, nodes.entry)] + for index, entry in enumerate(entries): + if index < len(columns): + columns[index].append(entry.astext()) + return tgroup, columns + + +def _fit(app, doctree, docname): + if app.builder.format != "latex": + return + for table in list(doctree.findall(nodes.table)): + if not any(c.startswith("NEEDS_") for c in table["classes"]): + continue + if set(table["ids"]) & doctree.get(_GIVEN, set()): + continue + tgroup, columns = _columns(table) + if not columns: + continue + line, size = LINE, None + for cls, smaller_line in SMALLER: + if _least_width(columns) <= line: + break + line, size = smaller_line, cls + colspecs = list(tgroup.findall(nodes.colspec)) + for colspec, width in zip(colspecs, column_widths(columns, line)): + colspec["colwidth"] = width + if "longtable" not in table["classes"]: + table["classes"].append("longtable") + for entry in list(tgroup.findall(nodes.entry)): + _add_breaks(entry) + wrapper = nodes.container("", classes=["zd-needtable"] + ([size] if size else [])) + table.replace_self(wrapper) + wrapper += table + + +def setup(app): + # Before sphinx-needs builds the need tables (doctree-resolved, 500). + app.connect("doctree-resolved", _note_given_widths, priority=450) + # After sphinx-needs filled the need tables (doctree-resolved, 500). + app.connect("doctree-resolved", _fit, priority=650) + return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_latex/zdocs.sty b/sphinx/_latex/zdocs.sty index e049188..801d230 100644 --- a/sphinx/_latex/zdocs.sty +++ b/sphinx/_latex/zdocs.sty @@ -132,6 +132,43 @@ \let\printglossary\relax \let\printglossaries\relax +% -- Long names and tables ------------------------------------------------------ +% +% Zephyr names are long and have no spaces: CONFIG_SAFE_DATA_LOCK_TIMEOUT_MS, +% TC_SAFE_DATA_WRITE_RESEALS. TeX does not break them, so in a table cell they +% go past the edge of the page. In a table and in a need card, a line can +% break after an underscore (\zdunderscorebreaks; penalty 100, so a space is +% still the better break). Not in the text of a page: there a name stays one +% word. (A link target is not changed: \sphinxhref reads its URL again, +% without this macro.) + +\newcommand{\zdunderscorebreaks}{% + \DeclareRobustCommand{\_}{% + \ifmmode\nfss@text{\textunderscore}\else\textunderscore\penalty100\relax\fi}% +} +% Sphinx starts each table with one of these two macros, in a group. +\g@addto@macro\sphinxattablestart{\zdunderscorebreaks} +\g@addto@macro\sphinxatlongtablestart{\zdunderscorebreaks} + +% Sphinx sets a table without given widths with tabulary, which gives each +% column a width in proportion to its text. A column of short values (need +% IDs) then becomes so narrow that each value breaks in two. A column is at +% least \tymin wide. + +\setlength{\tymin}{5.5em} + +% A need table (pdf_tables): ragged right, as a narrow column of justified +% text has large gaps. One that is too wide for the line: a smaller font and +% less padding in the cells. +% A cell resets the paragraph settings (array, varwidth), so the right margin +% is set where Sphinx starts each paragraph (\sphinxAtStartPar). Not with +% \raggedright: it changes \\, which ends a table row. +\newenvironment{sphinxclasszd-needtable}{% + \def\sphinxAtStartPar{\rightskip\z@\@plus 2em\relax\leavevmode\nobreak\hskip\z@skip}% +}{} +\newenvironment{sphinxclasszd-table-small}{\footnotesize\setlength{\tabcolsep}{3pt}}{} +\newenvironment{sphinxclasszd-table-smaller}{\scriptsize\setlength{\tabcolsep}{3pt}}{} + % -- Need cards ------------------------------------------------------------------ % % The need_cards extension writes each zdocs card (need_layout()) as: @@ -169,6 +206,7 @@ \par\addvspace{1.1\baselineskip}% \definecolor{zdAccent}{HTML}{#1}% \colorlet{zdToneaccent}{zdAccent}% + \zdunderscorebreaks \zd@needfieldsfalse \def\FrameCommand{{\color{zdAccent}\vrule\@width 2pt}\hspace{0.9em}}% \MakeFramed{\advance\hsize-\width\FrameRestore}% diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index dd8d583..72295e7 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -126,6 +126,9 @@ def configure( # In a PDF, a link to a local server or a relative HTML path (which # has no target next to the PDF) is written as text, with a warning. "pdf_links", + # In a PDF, a need table gets column widths from its content and + # breaks across pages. + "pdf_tables", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by From 1a5c5f6c7d2a0fc4e484988fb3214f75b883c8aa Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 20:06:39 +0200 Subject: [PATCH 4/5] feat: latex: a title page with the document data and the PDF set The Sphinx title page had the title, "Release <version>", the author and the date, right-aligned on an otherwise empty page. zdocs.sty defines \sphinxmaketitle again: the title and the version, then the document ID, the date and the author. If the PDF links to other PDFs of its set, the title page names their files and says that the links work only while the files are in one folder with these names. zdocs_conf gives the values: \zdsetup{docid, set}. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- sphinx/_extensions/_tests/test_zdocs_latex.py | 39 +++++++++- sphinx/_latex/zdocs.sty | 71 ++++++++++++++++++- sphinx/zdocs_conf.py | 9 ++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/sphinx/_extensions/_tests/test_zdocs_latex.py b/sphinx/_extensions/_tests/test_zdocs_latex.py index c7fa94e..8a82216 100644 --- a/sphinx/_extensions/_tests/test_zdocs_latex.py +++ b/sphinx/_extensions/_tests/test_zdocs_latex.py @@ -34,9 +34,39 @@ def test_the_pdf_loads_zdocs_sty_with_the_values_of_the_build(tmp_path, monkeypa assert "title={R\\&D Handbook}" in preamble assert "version={1.2}" in preamble assert "footer={Copyright " in preamble and "Smith \\& Co}" in preamble + assert "docid={handbook}" in preamble + assert "set={}" in preamble # no registry, no set assert "All rights reserved" not in preamble +def test_the_title_page_names_the_pdfs_of_the_set(tmp_path, monkeypatch): + class Refs: + version_scope = None + testmodule = None + symbol_needs = None + intersphinx_mapping = doxylink = needs_external_needs = reference_groups = {} + doxygen_docs = {} + pdf_peers = { + "https://x.example/html/test-report": "Test-Report_1.2.pdf", + "https://x.example/html/requirement-specification": "Requirement-Specification_1.2.pdf", + } + + registry = tmp_path / "documents.yaml" + registry.write_text("documents: {}\n") + monkeypatch.setenv("ZDOCS_REGISTRY", str(registry)) + monkeypatch.setattr(zdocs_conf.docrefs, "load", lambda **kwargs: Refs()) + monkeypatch.setenv("VERSION", "1.2") + doc_dir = tmp_path / "handbook" + doc_dir.mkdir() + namespace = {} + zdocs_conf.configure(namespace, doc_dir) + preamble = namespace["latex_elements"]["preamble"] + assert ( + "set={\\zdsetfile{Requirement-Specification\\_1.2.pdf}" + "\\zdsetfile{Test-Report\\_1.2.pdf}}" + ) in preamble + + def test_a_consumer_style_file_is_loaded_last(tmp_path, monkeypatch): style = tmp_path / "acme-pdf.sty" style.write_text("\\zdsetup{footer={ACME}}\n") @@ -55,7 +85,12 @@ def test_a_consumer_style_file_must_be_a_sty_of_its_own_name(tmp_path, monkeypat def test_zdocs_sty_defines_what_the_engine_writes(): sty = STY.read_text() # zdocs_conf writes \zdsetup, doc_control writes \signatureline. - for macro in ("\\newcommand{\\zdsetup}", "\\newcommand{\\signatureline}"): + for macro in ( + "\\newcommand{\\zdsetup}", + "\\newcommand{\\signatureline}", + "\\newcommand{\\zdsetfile}", + "\\renewcommand{\\sphinxmaketitle}", + ): assert macro in sty - for key in ("title", "version", "footer"): + for key in ("title", "version", "footer", "docid", "set"): assert f"\\define@key{{zd}}{{{key}}}" in sty diff --git a/sphinx/_latex/zdocs.sty b/sphinx/_latex/zdocs.sty index 801d230..ac6f406 100644 --- a/sphinx/_latex/zdocs.sty +++ b/sphinx/_latex/zdocs.sty @@ -20,11 +20,15 @@ % title the running header, left (default: the document title) % version the running header, right (default: the document version) % footer the running footer, left (default: Copyright <year> <holder>) +% docid the document ID in the registry (title page) +% set the PDFs of the set that this one links to, each as +% \zdsetfile{<file name>} (title page) % % Colors: \definecolor (xcolor) again in a consumer's style file. % % zdRule the lines of the running header and footer % zdMuted the text of the running header and footer +% zdLabel a label: of a field in a need card, of a row on the title page \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{zdocs}[2026/10/14 zdocs PDF layout] @@ -45,13 +49,18 @@ \def\zd@title{} \def\zd@version{} \def\zd@footer{} +\def\zd@docid{} +\def\zd@set{} \define@key{zd}{title}{\def\zd@title{#1}} \define@key{zd}{version}{\def\zd@version{#1}} \define@key{zd}{footer}{\def\zd@footer{#1}} +\define@key{zd}{docid}{\def\zd@docid{#1}} +\define@key{zd}{set}{\def\zd@set{#1}} \newcommand{\zdsetup}[1]{\setkeys{zd}{#1}} \definecolor{zdRule}{gray}{0.55} \definecolor{zdMuted}{gray}{0.30} +\definecolor{zdLabel}{gray}{0.40} % -- Fonts ------------------------------------------------------------------- % @@ -91,6 +100,67 @@ % fancyhdr 4.1 and newer: the font of the header and footer text. \ifdefined\fancyhfinit\fancyhfinit{\footnotesize\color{zdMuted}}\fi +% -- Title page ------------------------------------------------------------------ +% +% The title and the version, then the data of the document: ID, date, author. If the +% PDF links to other PDFs of its set, the title page names their files: the +% links work only while the files are in one folder. +% +% Like Sphinx's own \sphinxmaketitle (sphinxmanual.cls): the PDF information +% (title, author), and no page anchor on the title page. + +\definecolor{zdTitleRule}{gray}{0.25} + +\newcommand{\zdsetfile}[1]{\item[]\texttt{#1}} + +\newcommand{\zd@titlerow}[2]{% + \if\relax\detokenize{#2}\relax\else + {\color{zdLabel}#1} & #2\\[0.25\baselineskip] + \fi +} + +\renewcommand{\sphinxmaketitle}{% + \let\sphinxrestorepageanchorsetting\relax + \ifHy@pageanchor\def\sphinxrestorepageanchorsetting{\Hy@pageanchortrue}\fi + \hypersetup{pageanchor=false}% + \begin{titlepage}% + \begingroup + \def\endgraf{ }\def\and{\& }% + \pdfstringdefDisableCommands{\def\\{, }}% + \hypersetup{pdfauthor={\@author}, pdftitle={\@title}}% + \endgroup + \raggedright + \vspace*{0.12\textheight}% + {\color{zdTitleRule}\rule{\linewidth}{1.5pt}}\par + \vspace{1.2\baselineskip}% + {\py@HeaderFamily\Huge\@title\par}% + \vspace{0.6\baselineskip}% + {\Large\zd@version\par}% + \vspace{1.2\baselineskip}% + {\color{zdTitleRule}\rule{\linewidth}{0.4pt}}\par + \vspace{2\baselineskip}% + \begin{tabular}{@{}l@{\hspace{2em}}l@{}} + \zd@titlerow{Document}{\zd@docid}% + \zd@titlerow{Date}{\@date}% + \zd@titlerow{Author}{\@author}% + \end{tabular}\par + \vfill + \ifx\zd@set\@empty\else + {\small + \textbf{Linked documents.} This PDF links to the PDFs below. The links + work while these files are in the same folder as this one, with these + names.\par + \begin{itemize}\setlength{\itemsep}{0pt}\zd@set\end{itemize}}% + \vspace{\baselineskip}% + \fi + {\small\color{zdMuted}\zd@footer\par}% + \end{titlepage}% + \setcounter{footnote}{0}% + \let\thanks\relax\let\maketitle\relax + \clearpage + \sphinxrestorepageanchorsetting +} + % -- Contents ------------------------------------------------------------------ % % Sphinx puts the contents on a page of their own, under a chapter-size @@ -193,7 +263,6 @@ \RequirePackage{framed} -\definecolor{zdLabel}{gray}{0.40} \definecolor{zdTonegood}{HTML}{116329} \definecolor{zdTonebad}{HTML}{A40E26} \definecolor{zdTonegap}{HTML}{7D4E00} diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 72295e7..7f95528 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -208,12 +208,19 @@ def configure( # "Smith & Co", a document id with an underscore — and unescaped they are a # compile error at the very end of a long build, not a typo on a page. latex_files = [str(ZDOCS_DOC_DIR / "_latex" / "zdocs.sty")] + # The PDFs of the set that this one links to: the title page names them. + set_files = "".join( + f"\\zdsetfile{{{latex_escape(name)}}}" + for name in sorted(set(refs.pdf_peers.values()) if refs else ()) + ) latex_preamble = ( "\\usepackage{zdocs}\n" "\\zdsetup{" f"title={{{latex_escape(project)}}}, " f"version={{{latex_escape(version)}}}, " - f"footer={{Copyright {copyright_year} {latex_escape(holder)}}}" + f"docid={{{latex_escape(doc_id)}}}, " + f"footer={{Copyright {copyright_year} {latex_escape(holder)}}}, " + f"set={{{set_files}}}" "}\n" ) if latex_style: From 5bc5def53e48a6247335641c2e0ade9237bc24ac Mon Sep 17 00:00:00 2001 From: Tobias Kaestner <tobias.kaestner@inovex.de> Date: Wed, 14 Oct 2026 20:15:11 +0200 Subject: [PATCH 5/5] doc: the PDF layout in zdocs.sty (ADR 0014) ADR 0014: the PDF layout is a LaTeX package that changes Sphinx only through its documented hooks, loads no package that Sphinx does not load, and that a consumer's style file can change. A how-to for a consumer's style file (configure(latex_style=...), the \zdsetup keys, colors, fonts, the label width of a card), the card in a PDF in the directives reference, and pdf_tables in the API reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tobias Kaestner <tobias.kaestner@inovex.de> --- doc/api/python/extensions.rst | 11 ++- .../0014-pdf-layout-in-a-style-file.rst | 65 ++++++++++++ doc/manual/explanation/decisions/index.rst | 1 + .../howto/change-the-look-of-the-pdf.rst | 98 +++++++++++++++++++ doc/manual/howto/index.rst | 1 + doc/manual/reference/directives-and-roles.rst | 11 ++- 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 doc/manual/explanation/decisions/0014-pdf-layout-in-a-style-file.rst create mode 100644 doc/manual/howto/change-the-look-of-the-pdf.rst diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 7ff23e8..917c324 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -86,7 +86,7 @@ Re-reads a document when its sphinx-needs TOML file or the needs it imports chan ---------------- The type colors, the status and verdict classes and the previews of the zdocs -need cards. +need cards, and the card in a PDF. .. automodule:: need_cards :members: @@ -100,6 +100,15 @@ its text. .. automodule:: pdf_links :members: +``pdf_tables`` +---------------- + +Gives a need table in a PDF column widths from its content, and lets it break +across pages. + +.. automodule:: pdf_tables + :members: + ``xref_builder`` ------------------ diff --git a/doc/manual/explanation/decisions/0014-pdf-layout-in-a-style-file.rst b/doc/manual/explanation/decisions/0014-pdf-layout-in-a-style-file.rst new file mode 100644 index 0000000..fc349d0 --- /dev/null +++ b/doc/manual/explanation/decisions/0014-pdf-layout-in-a-style-file.rst @@ -0,0 +1,65 @@ +0014. The PDF layout is a LaTeX style file +========================================== + +Status +------ + +Accepted, 2026-10-14. + +Context +------- + +The look of a zdocs PDF was the Sphinx default with a few changes: strings +of LaTeX in ``zdocs_conf.py`` for the running header and footer. The result +had several problems: + +- The body text used a serif font, and the headings and the title page used + a sans-serif font. +- The contents were on a page of their own, and the title page was mostly + empty. +- A need card was a table of grey rows, because the card look is CSS, which + does not reach LaTeX. The fields came before the text, and their labels + looked like their values. +- A need table did not fit the page. + +A consumer could change the PDF only by replacing ``latex_elements`` in its +``conf.py``, which also drops the engine's settings. + +Decision +-------- + +- The engine has a LaTeX package, ``zdocs.sty`` (``sphinx/_latex/``). Each + PDF loads it after Sphinx's own package. ``zdocs_conf`` gives it only the + values of the build, with ``\zdsetup{...}``. +- ``zdocs.sty`` changes the Sphinx layout only through what Sphinx + documents: the page styles ``normal`` and ``plain``, ``\sphinxmaketitle``, + ``\sphinxtableofcontents``, the table hooks ``\sphinxattablestart`` and + ``\sphinxatlongtablestart``, the class environments ``sphinxclass<name>`` + and the paragraph start ``\sphinxAtStartPar``. It does not copy or patch + ``sphinx.sty``. +- ``zdocs.sty`` loads no package that a Sphinx xelatex build does not load + already. The need card uses ``framed``, not ``tcolorbox``. +- An extension writes semantic markup where a style needs it: ``need_cards`` + writes a zdocs card as ``zdneed`` with ``\zdneedhead``, ``\zdneedtitle`` + and ``\zdneedfield``; ``pdf_tables`` gives a need table its widths and its + class. The style file decides how this markup looks, as the CSS does for + the HTML. +- A consumer gives its own style file with ``configure(latex_style=...)``. + The PDF loads it last, so it can set the keys, colors, fonts and macros + again (:doc:`../../howto/change-the-look-of-the-pdf`). + +Consequences +------------ + +- The PDF layout is in one file, in LaTeX, and not in Python strings. +- A Sphinx upgrade can change a documented hook. Sphinx is pinned + (``requirements-doc.txt``), so an upgrade is a deliberate step, and the + acceptance suite builds a PDF. +- The look of a card is in two places: ``zdocs-needs.css`` for HTML and + ``zdocs.sty`` for the PDF. The accent of a type and the colors of a status + are the same in both; a change to one needs the same change to the other. +- A consumer that defines a macro of ``zdocs.sty`` again does not get the + changes of a new zdocs version for that macro. +- The default fonts stay the ones that Sphinx needs (FreeSans, FreeMono). A + consumer that wants other fonts must install them on each machine that + builds the PDF. diff --git a/doc/manual/explanation/decisions/index.rst b/doc/manual/explanation/decisions/index.rst index 0a26d7f..de582f0 100644 --- a/doc/manual/explanation/decisions/index.rst +++ b/doc/manual/explanation/decisions/index.rst @@ -32,3 +32,4 @@ repeating it. 0011-documentation-structure 0012-relative-cross-document-links 0013-linked-pdf-set + 0014-pdf-layout-in-a-style-file diff --git a/doc/manual/howto/change-the-look-of-the-pdf.rst b/doc/manual/howto/change-the-look-of-the-pdf.rst new file mode 100644 index 0000000..099e693 --- /dev/null +++ b/doc/manual/howto/change-the-look-of-the-pdf.rst @@ -0,0 +1,98 @@ +Change the look of the PDF +========================== + +The look of a zdocs PDF is in the LaTeX package ``zdocs.sty`` +(``sphinx/_latex/`` in the engine). Each PDF loads it after Sphinx's own +package. To change the look for your documents, give a style file of your +own. The PDF loads it after ``zdocs.sty``, so it can change what +``zdocs.sty`` sets. + +Give a style file +----------------- + +1. Put a ``.sty`` file in the folder of the document, for example + ``doc/handbook/acme-pdf.sty``. Do not call it ``zdocs.sty``. +2. Give its path to ``configure()`` in the document's ``conf.py``: + + .. code-block:: python + + from pathlib import Path + + from zdocs_conf import configure + + configure( + globals(), + Path(__file__).parent, + project="ACME Handbook", + latex_style=Path(__file__).parent / "acme-pdf.sty", + ) + + Use an absolute path: Sphinx reads ``conf.py`` from a copy of the + document folder in the build tree. +3. Build the PDF (``<document>-latex``). + +What a style file can change +---------------------------- + +Settings, with ``\zdsetup{key=value, ...}``. ``zdocs_conf`` sets these from +the build; a style file can set them again: + +``title`` + The running header, left. Default: the document title. +``version`` + The running header, right, and the title page. Default: the document + version. +``footer`` + The running footer, left, and the title page. Default: + ``Copyright <year> <holder>``. +``docid`` + The document ID on the title page. Default: the registry ID. + +Colors, with ``\definecolor`` (xcolor): + +``zdRule``, ``zdMuted`` + The lines and the text of the running header and footer. +``zdLabel`` + The labels of the fields in a need card, and of the rows on the title page. +``zdTonegood``, ``zdTonebad``, ``zdTonegap``, ``zdTonenone`` + The status badge of a need card: passed, failed, partial, not run. A + status of another value (for example ``approved``) has the accent color of + its need type. + +Fonts, with fontspec. The text and the headings use the sans-serif font, +the code the monospaced font: + +.. code-block:: latex + + \setsansfont{TeX Gyre Heros} + \setmonofont{TeX Gyre Cursor} + +The font must be in the TeX installation of each machine that builds the +PDF, also in CI. The default fonts (FreeSans, FreeMono) are the ones that a +Sphinx PDF needs already. + +The width of the label column of a need card: + +.. code-block:: latex + + \setlength{\zdneedlabelwidth}{9em} + +For a bigger change, a style file can define a macro of ``zdocs.sty`` again +(``\renewcommand``, ``\renewenvironment``): ``\sphinxmaketitle`` (the title +page), the environment ``zdneed`` and the macros ``\zdneedhead``, +``\zdneedtitle`` and ``\zdneedfield`` (the need card). Their arguments are in +the comments of ``zdocs.sty``. A macro that you define again does not get +the changes of a new zdocs version. + +An example +---------- + +.. code-block:: latex + + % acme-pdf.sty + \ProvidesPackage{acme-pdf} + \zdsetup{footer={ACME Instruments Ltd. -- Confidential}} + \definecolor{zdRule}{HTML}{005A9C} + \setlength{\zdneedlabelwidth}{8em} + +See also :doc:`../explanation/decisions/0014-pdf-layout-in-a-style-file`. diff --git a/doc/manual/howto/index.rst b/doc/manual/howto/index.rst index c2bfccb..5abb85a 100644 --- a/doc/manual/howto/index.rst +++ b/doc/manual/howto/index.rst @@ -10,5 +10,6 @@ assume you already have a docset and know your way around it. build-these-docs add-a-document publish-a-deploy-tree + change-the-look-of-the-pdf render-test-specifications run-the-test-suites diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 3c91c2d..9b2ed83 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -551,8 +551,15 @@ give the layout such a name: a verdict has the color of its value. The rows stay in the sphinx-needs order (head, fields, text) in the HTML; the -CSS shows the text before the fields. So the PDF and a screen reader still -give the fields first. +CSS shows the text before the fields. So a screen reader still gives the +fields first. + +In a PDF, ``need_cards`` writes the card with the LaTeX macros of +``zdocs.sty``: a bar in the accent color of the type on the left (no +background, so that it prints well), the head with the pill as a framed +badge, the title in bold, the text, then the fields and links with their +labels in one column. :doc:`../howto/change-the-look-of-the-pdf` tells how to +change it. The ``need_cards`` extension (loaded by ``zdocs_conf``) gives each card the ``color`` of its need type in ``needs_config.toml`` as the accent, and the