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`` + 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 (``-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 ``. +``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 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/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_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/_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/_tests/test_zdocs_latex.py b/sphinx/_extensions/_tests/test_zdocs_latex.py new file mode 100644 index 0000000..8a82216 --- /dev/null +++ b/sphinx/_extensions/_tests/test_zdocs_latex.py @@ -0,0 +1,96 @@ +# 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 "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") + 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}", + "\\newcommand{\\zdsetfile}", + "\\renewcommand{\\sphinxmaketitle}", + ): + assert macro in sty + for key in ("title", "version", "footer", "docid", "set"): + assert f"\\define@key{{zd}}{{{key}}}" in sty 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}{} + \zdneedhead{}{}{}{} + \zdneedtitle{} + <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/_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 new file mode 100644 index 0000000..ac6f406 --- /dev/null +++ b/sphinx/_latex/zdocs.sty @@ -0,0 +1,343 @@ +% 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 <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] + +\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{} +\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 ------------------------------------------------------------------- +% +% 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 + +% -- 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 +% 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 + +% -- 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: +% +% \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{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}% + \zdunderscorebreaks + \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 +% \signatureline{<role>}{<name>}. 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/_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; diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 15a2ee7..7f95528 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). @@ -142,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 @@ -212,71 +199,38 @@ 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")] + # 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 = ( - 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"docid={{{latex_escape(doc_id)}}}, " + f"footer={{Copyright {copyright_year} {latex_escape(holder)}}}, " + f"set={{{set_files}}}" + "}\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 +340,9 @@ def configure( # 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}", + # 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 +353,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"), ],