Coverage for /builds/svesch/cikit/src/cikit/sphinx_ext/chart.py: 100%
37 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
1# SPDX-License-Identifier: GPL-3.0-or-later
2"""Sphinx directive for embedding interactive cikit SVG charts.
4The directive embeds SVG charts as HTML objects so JavaScript contained in
5Pygal SVG files remains active in generated HTML documentation. Non-HTML
6builders receive a regular image node as a portable fallback.
8Author:
9 Sven Schlender
10Date:
11 2026-09-13
12"""
14from html import escape
16from docutils import nodes
17from docutils.parsers.rst import Directive, directives
18from sphinx import addnodes
21class CikitChartDirective(Directive):
22 """Embed an interactive SVG chart in Sphinx documentation.
24 HTML output uses an ``object`` element instead of an ``img`` element so
25 scripts and interactive behavior inside Pygal SVG charts remain active.
26 Other builders fall back to a regular Docutils image node.
27 """
29 required_arguments = 1
30 optional_arguments = 0
31 final_argument_whitespace = False
32 has_content = False
33 option_spec = {
34 "alt": directives.unchanged,
35 "width": directives.unchanged,
36 "height": directives.unchanged,
37 "class": directives.class_option,
38 }
40 def run(self):
41 """Create builder-specific nodes for the referenced SVG chart.
43 Returns:
44 Nodes containing an interactive HTML object and a non-HTML image
45 fallback.
46 """
47 uri = directives.uri(self.arguments[0])
48 alt = self.options.get("alt", "Interactive chart")
49 width = self.options.get("width", "100%")
50 height = self.options.get("height")
51 classes = ["cikit-chart", *self.options.get("class", [])]
53 html_node = addnodes.only(expr="html")
54 html_node += nodes.raw("", self._build_object(uri, alt, width, height, classes),
55 format="html")
57 image = nodes.image(uri=uri, alt=alt, classes=classes)
58 image["width"] = width
59 if height:
60 image["height"] = height
62 fallback_node = addnodes.only(expr="not html")
63 fallback_node += image
65 return [html_node, fallback_node]
67 @staticmethod
68 def _build_object(uri: str, alt: str, width: str, height: str, classes: list) -> str:
69 """Build the HTML object element used for interactive charts.
71 Args:
72 uri: SVG URI from the directive argument.
73 alt: Alternative text used for accessibility and fallback content.
74 width: CSS width assigned to the object element.
75 height: Optional CSS height assigned to the object element.
76 classes: CSS classes assigned to the object element.
78 Returns:
79 Escaped HTML object markup.
80 """
81 safe_uri = escape(uri, quote=True)
82 safe_alt = escape(alt, quote=True)
83 safe_classes = escape(" ".join(classes), quote=True)
84 style = f"width: {escape(width, quote=True)};"
85 if height:
86 style += f" height: {escape(height, quote=True)};"
88 return (
89 f'<object class="{safe_classes}" data="{safe_uri}" type="image/svg+xml" '
90 f'aria-label="{safe_alt}" style="{style}">'
91 f'<a href="{safe_uri}">{safe_alt}</a>'
92 "</object>"
93 )
96def setup(app):
97 """Register the cikit chart directive with Sphinx.
99 Args:
100 app: Active Sphinx application.
102 Returns:
103 Sphinx extension metadata.
104 """
105 app.add_directive("cikit-chart", CikitChartDirective)
107 return {
108 "version": "1.0",
109 "parallel_read_safe": True,
110 "parallel_write_safe": True,
111 }