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

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""Sphinx directive for embedding interactive cikit SVG charts. 

3 

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. 

7 

8Author: 

9 Sven Schlender 

10Date: 

11 2026-09-13 

12""" 

13 

14from html import escape 

15 

16from docutils import nodes 

17from docutils.parsers.rst import Directive, directives 

18from sphinx import addnodes 

19 

20 

21class CikitChartDirective(Directive): 

22 """Embed an interactive SVG chart in Sphinx documentation. 

23 

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 """ 

28 

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 } 

39 

40 def run(self): 

41 """Create builder-specific nodes for the referenced SVG chart. 

42 

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", [])] 

52 

53 html_node = addnodes.only(expr="html") 

54 html_node += nodes.raw("", self._build_object(uri, alt, width, height, classes), 

55 format="html") 

56 

57 image = nodes.image(uri=uri, alt=alt, classes=classes) 

58 image["width"] = width 

59 if height: 

60 image["height"] = height 

61 

62 fallback_node = addnodes.only(expr="not html") 

63 fallback_node += image 

64 

65 return [html_node, fallback_node] 

66 

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. 

70 

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. 

77 

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)};" 

87 

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 ) 

94 

95 

96def setup(app): 

97 """Register the cikit chart directive with Sphinx. 

98 

99 Args: 

100 app: Active Sphinx application. 

101 

102 Returns: 

103 Sphinx extension metadata. 

104 """ 

105 app.add_directive("cikit-chart", CikitChartDirective) 

106 

107 return { 

108 "version": "1.0", 

109 "parallel_read_safe": True, 

110 "parallel_write_safe": True, 

111 }