Files
ansible/lib/python3.10/site-packages/ansibleplaybookgrapher/postprocessor.py
2023-02-08 12:13:28 +01:00

218 lines
7.2 KiB
Python

# Copyright (C) 2022 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from typing import Dict, List
from ansible.utils.display import Display
from lxml import etree
from svg.path import parse_path
from ansibleplaybookgrapher.graph import PlaybookNode
display = Display()
DISPLAY_PREFIX = "postprocessor:"
JQUERY = "https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
def _read_data(filename: str) -> str:
"""
Read the script and return is as string
:param filename:
:return:
"""
current_dir = os.path.abspath(os.path.dirname(__file__))
javascript_path = os.path.join(current_dir, "data", filename)
with open(javascript_path) as javascript:
return javascript.read()
class GraphVizPostProcessor:
"""
Post process the svg by adding some javascript and css
"""
def __init__(self, svg_path: str):
"""
:param svg_path:
"""
self.svg_path = svg_path
self.tree = etree.parse(svg_path)
self.root = self.tree.getroot()
def insert_script_tag(self, index: int, attrib: Dict):
"""
:param index:
:param attrib:
:return:
"""
element_script_tag = etree.Element("script", attrib=attrib)
self.root.insert(index, element_script_tag)
def insert_cdata(self, index: int, tag: str, attrib: Dict, cdata_text: str):
"""
Insert cdata in the SVG
:param index:
:param tag:
:param attrib:
:param cdata_text:
:return:
"""
element = etree.Element(tag, attrib=attrib)
element.text = etree.CDATA(cdata_text)
self.root.insert(index, element)
def post_process(self, playbook_nodes: List[PlaybookNode] = None, *args, **kwargs):
"""
:param playbook_nodes:
:param args:
:param kwargs:
:return:
"""
self.root.set("id", "svg")
# insert jquery
self.insert_script_tag(
0, attrib={"type": "text/javascript", "href": JQUERY, "id": "jquery"}
)
# insert my javascript
self.insert_cdata(
1,
"script",
attrib={"type": "text/javascript", "id": "my_javascript"},
cdata_text=_read_data("highlight-hover.js"),
)
# insert my css
self.insert_cdata(
2,
"style",
attrib={"type": "text/css", "id": "my_css"},
cdata_text=_read_data("graph.css"),
)
# Curve the text on the edges
self._curve_text_on_edges()
playbook_nodes = playbook_nodes or []
for p_node in playbook_nodes:
# Insert the graph representation for the links between the nodes
self._insert_links(p_node)
def write(self, output_filename: str = None):
"""
Write the svg in the given filename
:param output_filename:
:return:
"""
if output_filename is None:
output_filename = self.svg_path
self.tree.write(output_filename, xml_declaration=True, encoding="UTF-8")
def _insert_links(self, playbook_node: PlaybookNode):
"""
Insert the links between nodes in the SVG file.
:param playbook_node: one of the playbook in the svg
"""
display.vv(f"Inserting links structure for the playbook '{playbook_node.name}'")
links_structure = playbook_node.links_structure()
for node, node_links in links_structure.items():
# Find the group g with the specified id
xpath_result = self.root.xpath(
f"ns:g/*[@id='{node.id}']", namespaces={"ns": SVG_NAMESPACE}
)
if xpath_result:
element = xpath_result[0]
root_subelement = etree.Element("links")
for counter, link in enumerate(node_links, 1):
root_subelement.append(
etree.Element(
"link",
attrib={
"target": link.id,
"edge": f"edge_{counter}_{node.id}_{link.id}",
},
)
)
element.append(root_subelement)
def _get_text_path_start_offset(self, path_element, text: str) -> str:
"""
Get the start offset where the edge label should begin
:param path_element:
:param text:
:return:
"""
# Get Bézier curve
path_segments = parse_path(path_element.get("d"))
# The segments usually contain 3 elements: One MoveTo and one or two CubicBezier objects.
# This is relatively slow to compute. Decreasing the "error" will drastically slow down the post-processing
segment_length = path_segments.length(error=1e-4)
text_length = len(text)
# We put the label closer to the target node
offset_factor = 0.76
start_offset = segment_length * offset_factor - text_length
msg = f"{DISPLAY_PREFIX} {len(path_segments)} segment(s) found for the path '{path_element.get('id')}', "
msg += f"segment_length={segment_length}, start_offset={start_offset}, text_length={text_length}"
display.vvvvv(msg)
return str(start_offset)
def _curve_text_on_edges(self):
"""
Update the text on each edge to curve it based on the edge
:return:
"""
# Fetch all edges
edge_elements = self.root.xpath(
"ns:g/*[starts-with(@id,'edge_')]", namespaces={"ns": SVG_NAMESPACE}
)
for edge in edge_elements:
text_element = edge.find(".//text", namespaces=self.root.nsmap)
# Define an ID for the path so that we can reference it explicitly
path_id = f"path_{edge.get('id')}"
path_element = edge.find(".//path", namespaces=self.root.nsmap)
path_element.set("id", path_id)
# Create a curved textPath: the text will follow the path
text_path = etree.Element("textPath")
text_path.set("{http://www.w3.org/1999/xlink}href", f"#{path_id}")
text_path.text = text_element.text
offset = self._get_text_path_start_offset(path_element, text_path.text)
text_path.set("startOffset", offset)
text_element.append(text_path)
dy = -0.2
text_element.set("dy", f"{dy}%")
# Remove unnecessary attributes and clear the text
text_element.attrib.pop("x", "")
text_element.attrib.pop("y", "")
text_element.text = None