From 7cdd0c15b4afdeb1433e8ed870bb12e3411f8e6c Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 15:49:47 +0000 Subject: [PATCH 1/2] Fix scenarios not in appendix --- doc/_ext/scenario_directive.py | 252 ++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 114 deletions(-) diff --git a/doc/_ext/scenario_directive.py b/doc/_ext/scenario_directive.py index abe3b0e89..e04bc02dd 100644 --- a/doc/_ext/scenario_directive.py +++ b/doc/_ext/scenario_directive.py @@ -38,11 +38,11 @@ import html import os import re +import textwrap from typing import Dict, FrozenSet, List, Tuple from docutils import nodes from docutils.parsers.rst import Directive, directives -from docutils.statemachine import StringList from sphinx.util import logging from sphinx.util.nodes import make_refnode @@ -66,6 +66,18 @@ class ScenarioAppendixRef(nodes.General, nodes.Element): """ +class ScenarioIncludePlaceholder(nodes.General, nodes.Element): + """Builder-agnostic placeholder emitted by ``scenario-include`` at parse-time. + + Resolved at ``doctree-resolved`` time (after the builder is known with + certainty) to either a :class:`ScenarioAppendixRef` (PDF/LaTeX) or inline + expandable HTML blocks. Storing a placeholder rather than the final nodes + fixes a caching bug: when an HTML build writes doctrees to disk and a + subsequent LaTeX build reuses them, the ``literalinclude`` nodes that the + old HTML-mode path emitted would appear inline instead of in the appendix. + """ + + # --------------------------------------------------------------------------- # Helper functions # --------------------------------------------------------------------------- @@ -152,6 +164,10 @@ class ScenarioIncludeDirective(Directive): In PDF/LaTeX builds the examples are moved to a dedicated appendix and replaced inline by a cross-reference, unless ``:inline:`` is given. + + Always emits a :class:`ScenarioIncludePlaceholder` node so the doctree + cache is builder-agnostic. The actual HTML or PDF content is chosen at + ``doctree-resolved`` time by :func:`resolve_scenario_include_placeholders`. """ required_arguments = 1 @@ -169,9 +185,6 @@ class ScenarioIncludeDirective(Directive): def _env(self): return self.state.document.settings.env - def _is_pdf(self) -> bool: - return self._env().app.builder.name in ("latex", "rinoh") - def _feature_abs(self, feature_file: str) -> str: env = self._env() path = os.path.abspath(os.path.join(env.app.srcdir, feature_file)) @@ -179,25 +192,6 @@ def _feature_abs(self, feature_file: str) -> str: raise self.error(f"Feature file not found: {path}") return path - def _include_path(self, feature_abs: str) -> str: - """Path for literalinclude, relative to the current RST document.""" - env = self._env() - current_doc_dir = os.path.dirname(os.path.join(env.app.srcdir, env.docname)) - return os.path.relpath(feature_abs, current_doc_dir) - - @staticmethod - def _end_before(available: Tuple[Tuple[str, str], ...], title: str) -> str: - """Return the :end-before: value for *title*, or '' for the last scenario.""" - all_titles = tuple(t for _, t in available) - try: - idx = all_titles.index(title) - except ValueError: - return "" - if idx < len(available) - 1: - next_header, next_title = available[idx + 1] - return f":end-before: {next_header}: {next_title}" - return "" - def _requested_scenarios(self, available: Tuple[Tuple[str, str], ...]) -> List[str]: return [ t.strip() @@ -206,67 +200,36 @@ def _requested_scenarios(self, available: Tuple[Tuple[str, str], ...]) -> List[s ] or [title for _, title in available] # ------------------------------------------------------------------ - # HTML / inline rendering (original behaviour) + # Appendix entry registration (always runs, any builder) # ------------------------------------------------------------------ - def _render_html( - self, - include_path: str, - feature_file: str, - scenario_titles: List[str], - available: Tuple[Tuple[str, str], ...], - ) -> List[nodes.Node]: - title_to_header = {title: header for header, title in available} - container = nodes.section() - for title in scenario_titles: - header = title_to_header.get(title, "Scenario") - end_before = self._end_before(available, title) - directive_rst = f""" -.. raw:: html - -
- Example: {html.escape(title)} - -.. literalinclude:: {include_path} - :language: gherkin - :caption: {feature_file} - :force: - :dedent: - :start-after: {header}: {title} - {end_before} - -.. raw:: html - -
-""" - viewlist = StringList() - for i, line in enumerate(directive_rst.splitlines()): - viewlist.append(line, source=f"<{self.name} directive>", offset=i) - self.state.nested_parse(viewlist, self.content_offset, container) - return container.children - - # ------------------------------------------------------------------ - # PDF rendering: register for appendix, return cross-reference - # ------------------------------------------------------------------ + def _entry_metadata(self, feature_abs: str) -> Tuple[str, str, str]: + """Return (label, group_tag, feature_title) for a feature file.""" + env = self._env() + non_group_tags = frozenset(getattr(env.config, "scenario_non_command_tags", [])) + basename = os.path.splitext(os.path.basename(feature_abs))[0] + return ( + f"appendix-{basename}", + _group_tag(feature_abs, non_group_tags), + _feature_title(feature_abs), + ) - def _render_pdf( + def _register_appendix_entry( self, feature_file: str, feature_abs: str, scenario_titles: List[str], - ) -> List[nodes.Node]: + ) -> None: + """Store entry in env.scenario_appendix_entries for any builder. + + Always called so the appendix is populated even when switching from + an HTML build to a LaTeX build with a shared doctrees cache: entries + written during the HTML read are still present in the pickled env and + available when the LaTeX build runs without a full ``-E`` rebuild. + """ env = self._env() - non_group_tags = frozenset(getattr(env.config, "scenario_non_command_tags", [])) - basename = os.path.splitext(os.path.basename(feature_abs))[0] - label = f"appendix-{basename}" - tag = _group_tag(feature_abs, non_group_tags) - title = _feature_title(feature_abs) - - # ---------------------------------------------------------- - # Store entry so the appendix directive can render it later. - # The dict is keyed by absolute path to deduplicate across - # multiple RST files that reference the same feature. - # ---------------------------------------------------------- + label, tag, title = self._entry_metadata(feature_abs) + if not hasattr(env, "scenario_appendix_entries"): env.scenario_appendix_entries = {} @@ -287,21 +250,6 @@ def _render_pdf( if s not in existing["scenarios"]: existing["scenarios"].append(s) - # ---------------------------------------------------------- - # Return a deferred ScenarioAppendixRef block node. It is - # resolved to a full paragraph with make_refnode links during - # doctree-resolved, once the appendix document name is known. - # Sphinx's make_refnode handles LaTeX/HTML builder differences, - # producing a proper \hyperref in PDF output. - # ---------------------------------------------------------- - ref_node = ScenarioAppendixRef() - ref_node["label"] = label - ref_node["reftitle"] = title - ref_node["group_tag"] = tag - ref_node["group_label"] = f"appendix-{tag}" - ref_node["scenario_count"] = len(scenario_titles) - return [ref_node] - # ------------------------------------------------------------------ # Entry point # ------------------------------------------------------------------ @@ -325,15 +273,20 @@ def run(self) -> List[nodes.Node]: if not scenario_titles: raise self.error(f"No scenarios matched in {feature_file}.") - if self._is_pdf() and "inline" not in self.options: - return self._render_pdf(feature_file, feature_abs, scenario_titles) + self._register_appendix_entry(feature_file, feature_abs, scenario_titles) - return self._render_html( - self._include_path(feature_abs), - feature_file, - scenario_titles, - available, - ) + label, tag, title = self._entry_metadata(feature_abs) + placeholder = ScenarioIncludePlaceholder() + placeholder["feature_abs"] = feature_abs + placeholder["feature_file"] = feature_file + placeholder["scenario_titles"] = scenario_titles + placeholder["inline_flag"] = "inline" in self.options + placeholder["label"] = label + placeholder["reftitle"] = title + placeholder["group_tag"] = tag + placeholder["group_label"] = f"appendix-{tag}" + placeholder["scenario_count"] = len(scenario_titles) + return [placeholder] # --------------------------------------------------------------------------- @@ -359,22 +312,16 @@ class ScenarioAppendixDirective(Directive): def run(self) -> List[nodes.Node]: env = self.state.document.settings.env - if env.app.builder.name not in ("latex", "rinoh"): - note = nodes.note() - para = nodes.paragraph() - para += nodes.Text( - "In the HTML edition, feature examples appear as expandable " - "blocks directly within each guide section. " - "In the PDF edition they are collected here, grouped by command." - ) - note += para - return [note] - # Record which document hosts the appendix so that ScenarioAppendixRef # nodes in other documents can be resolved with the correct refdocname. + # Set unconditionally so the value survives a cached HTML→LaTeX switch. env.scenario_appendix_docname = env.docname - node = ScenarioAppendixPlaceholder() - return [node] + # Always emit the placeholder node. process_scenario_appendix replaces + # it with real content in PDF builds and with an explanatory note in + # HTML builds. Keeping the same node type in the cached doctree means + # a subsequent LaTeX build can still find and populate the appendix even + # when the doctrees were last written during an HTML build. + return [ScenarioAppendixPlaceholder()] # --------------------------------------------------------------------------- @@ -418,6 +365,58 @@ def _build_appendix_nodes(entries: Dict) -> List[nodes.Node]: return result +def _render_scenario_inline( + scenario_titles: List[str], feature_abs: str +) -> List[nodes.Node]: + """Return docutils nodes for inline HTML rendering of *scenario_titles*.""" + result: List[nodes.Node] = [] + for title in scenario_titles: + raw_content = _selected_scenarios_content(feature_abs, [title]) + content = textwrap.dedent(raw_content).strip() + open_raw = nodes.raw( + "", + f"
\nExample: {html.escape(title)}\n", + format="html", + ) + code = nodes.literal_block(content, content) + code["language"] = "gherkin" + close_raw = nodes.raw("", "
\n", format="html") + result.extend([open_raw, code, close_raw]) + return result + + +def resolve_scenario_include_placeholders( + app, doctree: nodes.document, _fromdocname: str +) -> None: + """Replace ScenarioIncludePlaceholder nodes with builder-appropriate content. + + Called first among the ``doctree-resolved`` handlers so the + :class:`ScenarioAppendixRef` nodes it creates are available to + :func:`resolve_scenario_appendix_refs` in the same pass. + + PDF/LaTeX builds get an appendix cross-reference; HTML (and any directive + marked ``:inline:``) gets expandable ``
`` blocks. Because this + transform runs at write-time rather than parse-time, the doctree cache is + builder-agnostic: a LaTeX build reusing an HTML-built doctree will still + route scenario content to the appendix. + """ + is_pdf = app.builder.name in ("latex", "rinoh") + for placeholder in list(doctree.traverse(ScenarioIncludePlaceholder)): + if is_pdf and not placeholder.get("inline_flag", False): + ref_node = ScenarioAppendixRef() + ref_node["label"] = placeholder["label"] + ref_node["reftitle"] = placeholder["reftitle"] + ref_node["group_tag"] = placeholder["group_tag"] + ref_node["group_label"] = placeholder["group_label"] + ref_node["scenario_count"] = placeholder["scenario_count"] + placeholder.replace_self([ref_node]) + else: + inline_nodes = _render_scenario_inline( + placeholder["scenario_titles"], placeholder["feature_abs"] + ) + placeholder.replace_self(inline_nodes) + + def resolve_scenario_appendix_refs( app, doctree: nodes.document, fromdocname: str ) -> None: @@ -482,11 +481,32 @@ def resolve_scenario_appendix_refs( def process_scenario_appendix(app, doctree: nodes.document, _fromdocname: str) -> None: - """Replace ScenarioAppendixPlaceholder nodes with generated content.""" + """Replace ScenarioAppendixPlaceholder nodes with generated content. + + In PDF/LaTeX builds the placeholder is replaced with the collected feature + sections. In HTML builds it is replaced with an explanatory note — the + same text that was previously emitted inline by the directive itself. + Deferring this choice to write-time (rather than parse-time) keeps the + same node type in the cached doctree, so a LaTeX build can still populate + the appendix even when the doctrees were last written by an HTML build. + """ placeholders = list(doctree.traverse(ScenarioAppendixPlaceholder)) if not placeholders: return + if app.builder.name not in ("latex", "rinoh"): + note = nodes.note() + para = nodes.paragraph() + para += nodes.Text( + "In the HTML edition, feature examples appear as expandable " + "blocks directly within each guide section. " + "In the PDF edition they are collected here, grouped by command." + ) + note += para + for placeholder in placeholders: + placeholder.replace_self([note]) + return + entries = getattr(app.env, "scenario_appendix_entries", {}) appendix_nodes = _build_appendix_nodes(entries) if entries else [] @@ -538,13 +558,17 @@ def setup(app): app.add_directive("scenario-appendix", ScenarioAppendixDirective) app.add_node(ScenarioAppendixPlaceholder) app.add_node(ScenarioAppendixRef) + app.add_node(ScenarioIncludePlaceholder) + # resolve_scenario_include_placeholders must run first: it may produce + # ScenarioAppendixRef nodes that resolve_scenario_appendix_refs then links. + app.connect("doctree-resolved", resolve_scenario_include_placeholders) app.connect("doctree-resolved", resolve_scenario_appendix_refs) app.connect("doctree-resolved", process_scenario_appendix) app.connect("env-purge-doc", purge_scenario_appendix) app.connect("env-merge-info", merge_scenario_appendix) return { - "version": "0.3", + "version": "0.4", "parallel_read_safe": True, "parallel_write_safe": True, } From 4f2167ff12ca5d2e19425aa949e3e8bbdc88f43e Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 16:05:47 +0000 Subject: [PATCH 2/2] Fix colordot in pdf --- doc/_ext/colordot.py | 83 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/doc/_ext/colordot.py b/doc/_ext/colordot.py index 682661af4..9295ab79d 100644 --- a/doc/_ext/colordot.py +++ b/doc/_ext/colordot.py @@ -1,8 +1,13 @@ -"""Sphinx role ``colordot`` for inline colour swatches. +"""Sphinx extension for inline colour swatches and PDF emoji substitution. -Renders an inline ```` -so RST tables can show a filled colour circle next to a hex value without -resorting to raw HTML blocks. +The ``:colordot:`` role renders a filled colour circle as a CSS ```` +in HTML builds. + +For PDF/LaTeX builds this extension also replaces the coloured-circle emoji +used as severity/risk badges in the threat-model tables with equivalent +TikZ-drawn circles. The body font (TeX Gyre Heroes) has no glyphs for these +supplementary-plane code points so without this replacement XeLaTeX silently +drops them. Usage in .rst files:: @@ -14,12 +19,17 @@ """ import html -from typing import Any +import re +from typing import Any, List from docutils import nodes from docutils.nodes import Node, system_message from sphinx.application import Sphinx +# --------------------------------------------------------------------------- +# :colordot: role — HTML colour swatch +# --------------------------------------------------------------------------- + def colordot_role( # pylint: disable=too-many-arguments,too-many-positional-arguments name: str, # pylint: disable=unused-argument @@ -50,8 +60,66 @@ def colordot_role( # pylint: disable=too-many-arguments,too-many-positional-arg return [node], [] +# --------------------------------------------------------------------------- +# Emoji → LaTeX replacement for PDF builds +# --------------------------------------------------------------------------- + +# Coloured-circle emoji used as severity/risk badges in the threat-model +# tables. Map each code point to a TikZ fill command that produces a +# matching coloured disc in XeLaTeX output. +_EMOJI_LATEX: dict[str, str] = { + "\U0001f7e2": r"\,\tikz[baseline=-0.5ex]{\fill[green!70!black](0,0)circle(0.45ex);}\,", # 🟢 + "\U0001f7e1": r"\,\tikz[baseline=-0.5ex]{\fill[yellow!70!black](0,0)circle(0.45ex);}\,", # 🟡 + "\U0001f7e0": r"\,\tikz[baseline=-0.5ex]{\fill[orange!90!black](0,0)circle(0.45ex);}\,", # 🟠 + "\U0001f534": r"\,\tikz[baseline=-0.5ex]{\fill[red!70!black](0,0)circle(0.45ex);}\,", # 🔴 +} + +_EMOJI_RE = re.compile("|".join(re.escape(c) for c in _EMOJI_LATEX)) + + +def _replace_emoji_for_latex( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, # pylint: disable=unused-argument +) -> None: + """Replace coloured-circle emoji with TikZ circles in LaTeX/PDF builds. + + Args: + app: The Sphinx application object. + doctree: The resolved document tree being processed. + fromdocname: The source document name (unused). + """ + if app.builder.name not in ("latex", "rinoh"): + return + + targets = [n for n in doctree.traverse(nodes.Text) if _EMOJI_RE.search(str(n))] + for text_node in targets: + text = str(text_node) + parent = text_node.parent + if parent is None: + continue + idx = parent.children.index(text_node) + new_nodes: List[Node] = [] + last = 0 + for m in _EMOJI_RE.finditer(text): + if m.start() > last: + new_nodes.append(nodes.Text(text[last : m.start()])) + raw = nodes.raw("", _EMOJI_LATEX[m.group()], format="latex") + raw.parent = parent + new_nodes.append(raw) + last = m.end() + if last < len(text): + new_nodes.append(nodes.Text(text[last:])) + parent.children[idx : idx + 1] = new_nodes + + +# --------------------------------------------------------------------------- +# Sphinx extension setup +# --------------------------------------------------------------------------- + + def setup(app: Sphinx) -> dict[str, Any]: - """Register the ``colordot`` role with Sphinx. + """Register the ``colordot`` role and PDF emoji handler with Sphinx. Args: app: The Sphinx application object. @@ -60,4 +128,5 @@ def setup(app: Sphinx) -> dict[str, Any]: Extension metadata dictionary. """ app.add_role("colordot", colordot_role) - return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} + app.connect("doctree-resolved", _replace_emoji_for_latex) + return {"version": "0.2", "parallel_read_safe": True, "parallel_write_safe": True}