# -*- coding: utf-8 -*-
import re
from itertools import zip_longest
from typing import Any, List, Optional, Union
from wireviz import APP_NAME, APP_URL, __version__
from wireviz.DataClasses import (
Cable,
Color,
Component,
Connection,
Connector,
Options,
ShieldClass,
WireClass,
)
from wireviz.wv_colors import get_color_hex, translate_color
from wireviz.wv_helper import pn_info_string, remove_links
from wireviz.wv_table_util import * # TODO: explicitly import each needed tag later
HEADER_PN = "P/N"
HEADER_MPN = "MPN"
HEADER_SPN = "SPN"
def gv_node_component(
component: Component, harness_options: Options, pad=None
) -> Table:
# If no wires connected (except maybe loop wires)?
if isinstance(component, Connector):
if not (component.ports_left or component.ports_right):
component.ports_left = True # Use left side pins by default
# generate all rows to be shown in the node
if component.show_name:
str_name = f"{remove_links(component.name)}"
line_name = colored_cell(str_name, component.bgcolor_title)
else:
line_name = None
line_pn = part_number_str_list(component)
if isinstance(component, Connector):
line_info = [
html_line_breaks(component.type),
html_line_breaks(component.subtype),
f"{component.pincount}-pin" if component.show_pincount else None,
translate_color(component.color, harness_options.color_mode),
colorbar_cell(component.color),
]
elif isinstance(component, Cable):
line_info = [
html_line_breaks(component.type),
f"{component.wirecount}x" if component.show_wirecount else None,
f"{component.gauge_str}" if component.gauge else None,
"+ S" if component.shield else None,
f"{component.length} {component.length_unit}"
if component.length > 0
else None,
translate_color(component.color, harness_options.color_mode),
colorbar_cell(component.color),
]
line_image, line_image_caption = image_and_caption_cells(component)
# line_additional_component_table = get_additional_component_table(self, connector)
line_additional_component_table = None
line_notes = [html_line_breaks(component.notes)]
if isinstance(component, Connector):
if component.style != "simple":
line_ports = gv_pin_table(component)
else:
line_ports = None
elif isinstance(component, Cable):
line_ports = gv_conductor_table(component, harness_options)
lines = [
line_name,
line_pn,
line_info,
line_ports,
line_image,
line_image_caption,
line_additional_component_table,
line_notes,
]
if component.bgcolor:
tbl_bgcolor = translate_color(component.bgcolor, "HEX")
elif isinstance(component, Connector) and harness_options.bgcolor_connector:
tbl_bgcolor = translate_color(harness_options.bgcolor_connector, "HEX")
elif isinstance(component, Cable) and harness_options.bgcolor_cable:
tbl_bgcolor = translate_color(harness_options.bgcolor_cable, "HEX")
tbl = nested_table(lines)
tbl.update_attribs(bgcolor=tbl_bgcolor)
return tbl
def make_list_of_cells(inp) -> List[Td]:
# inp may be List,
if isinstance(inp, List):
# ensure all list items are Td
list_out = [item if isinstance(item, Td) else Td(item) for item in inp]
return list_out
else:
if inp is None:
return []
if isinstance(inp, Td):
return [inp]
else:
return [Td(inp)]
def nested_table(lines: List[Td]) -> Table:
cell_lists = [make_list_of_cells(line) for line in lines]
rows = []
for lst in cell_lists:
if len(lst) == 0:
continue # no cells in list
cells = [item for item in lst if item.contents is not None]
if len(cells) == 0:
continue # no cells in list that are not None
if (
len(cells) == 1
and isinstance(cells[0].contents, Table)
and not "!" in cells[0].contents.attribs.get("id", "")
):
# cell content is already a table, no need to re-wrap it;
# unless explicitly asked to by a "!" in the ID field
# as used by image_and_caption_cells()
inner_table = cells[0].contents
else:
# nest cell content inside a table
inner_table = Table(
Tr(cells), border=0, cellspacing=0, cellpadding=3, cellborder=1
)
rows.append(Tr(Td(inner_table)))
if len(rows) == 0: # create dummy row to avoid GraphViz errors due to empty
rows = Tr(Td(""))
tbl = Table(rows, border=0, cellspacing=0, cellpadding=0)
return tbl
def gv_pin_table(component) -> Table:
pin_tuples = zip_longest(
component.pins,
component.pinlabels,
component.pincolors,
)
pin_rows = []
for pinindex, (pinname, pinlabel, pincolor) in enumerate(pin_tuples):
if component.should_show_pin(pinname):
pin_rows.append(
gv_pin_row(pinindex, pinname, pinlabel, pincolor, component)
)
tbl = Table(pin_rows, border=0, cellspacing=0, cellpadding=3, cellborder=1)
return tbl
def gv_pin_row(pin_index, pin_name, pin_label, pin_color, connector) -> Tr:
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
cell_pin_left = Td(pin_name, port=f"p{pin_index+1}l")
cell_pin_label = Td(pin_label, delete_if_empty=True)
cell_pin_right = Td(pin_name, port=f"p{pin_index+1}r")
cells = [
cell_pin_left if connector.ports_left else None,
cell_pin_label,
cell_pin_right if connector.ports_right else None,
]
return Tr(cells)
def gv_connector_loops(connector: Connector) -> List:
loop_edges = []
if connector.ports_left:
loop_side = "l"
loop_dir = "w"
elif connector.ports_right:
loop_side = "r"
loop_dir = "e"
else:
raise Exception("No side for loops")
for loop in connector.loops:
head = f"{connector.name}:p{loop[0]}{loop_side}:{loop_dir}"
tail = f"{connector.name}:p{loop[1]}{loop_side}:{loop_dir}"
loop_edges.append((head, tail))
return loop_edges
def gv_conductor_table(cable, harness_options) -> Table:
rows = []
rows.append(Tr(Td(" "))) # spacer row on top
inserted_break_inbetween = False
for wire in cable.wire_objects:
# insert blank space between wires and shields
if isinstance(wire, ShieldClass) and not inserted_break_inbetween:
rows.append(Tr(Td(" "))) # spacer row between wires and shields
inserted_break_inbetween = True
# row above the wire
wireinfo = []
if cable.show_wirenumbers and not isinstance(wire, ShieldClass):
wireinfo.append(str(wire.id))
wireinfo.append(translate_color(wire.color, harness_options.color_mode))
wireinfo.append(wire.label)
ins, outs = [], []
for conn in cable.connections:
if conn.via.id == wire.id:
if conn.from_ is not None:
from_label = f":{conn.from_.label}" if conn.from_.label else ""
ins.append(f"{conn.from_.parent}:{conn.from_.id}{from_label}")
if conn.to is not None:
to_label = f":{conn.to.label}" if conn.to.label else ""
outs.append(f"{conn.to.parent}:{conn.to.id}{to_label}")
cells_above = [
Td(", ".join(ins)),
Td(":".join([wi for wi in wireinfo if wi is not None])),
Td(", ".join(outs)),
]
rows.append(Tr(cells_above))
# the wire itself
rows.append(Tr(gv_wire_cell(wire, padding=harness_options._pad)))
# row below the wire
# TODO: PN stuff for bundles
# wire_pn_stuff() see below
rows.append(Tr(Td(" "))) # spacer row on bottom
tbl = Table(rows, border=0, cellspacing=0, cellborder=0)
return tbl
def gv_wire_cell(wire: Union[WireClass, ShieldClass], padding) -> Td:
if wire.color:
color_list = ["#000000"] + get_color_hex(wire.color, pad=padding) + ["#000000"]
else:
color_list = ["#000000"]
wire_inner_rows = []
for j, bgcolor in enumerate(color_list[::-1]):
wire_inner_cell_attribs = {
"colspan": 3,
"cellpadding": 0,
"height": 2,
"border": 0,
"bgcolor": bgcolor if bgcolor != "" else "BK",
}
wire_inner_rows.append(Tr(Td("", **wire_inner_cell_attribs)))
wire_inner_table = Table(wire_inner_rows, cellspacing=0, cellborder=0, border=0)
wire_outer_cell_attribs = {
"colspan": 3,
"border": 0,
"cellspacing": 0,
"port": f"w{wire.index+1}",
"height": 2 * len(color_list),
}
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
wire_outer_cell = Td(wire_inner_table, **wire_outer_cell_attribs)
return wire_outer_cell
def wire_pn_stuff():
# # for bundles, individual wires can have part information
# if cable.category == "bundle":
# # create a list of wire parameters
# wireidentification = []
# if isinstance(cable.pn, list):
# wireidentification.append(
# pn_info_string(
# HEADER_PN, None, remove_links(cable.pn[i - 1])
# )
# )
# manufacturer_info = pn_info_string(
# HEADER_MPN,
# cable.manufacturer[i - 1]
# if isinstance(cable.manufacturer, list)
# else None,
# cable.mpn[i - 1] if isinstance(cable.mpn, list) else None,
# )
# supplier_info = pn_info_string(
# HEADER_SPN,
# cable.supplier[i - 1]
# if isinstance(cable.supplier, list)
# else None,
# cable.spn[i - 1] if isinstance(cable.spn, list) else None,
# )
# if manufacturer_info:
# wireidentification.append(html_line_breaks(manufacturer_info))
# if supplier_info:
# wireidentification.append(html_line_breaks(supplier_info))
# # print parameters into a table row under the wire
# if len(wireidentification) > 0:
# # fmt: off
# wirehtml.append(' ')
# wirehtml.append(' ')
# for attrib in wireidentification:
# wirehtml.append(f" | {attrib} | ")
# wirehtml.append(" ")
# wirehtml.append(" |
")
# # fmt: on
pass
def gv_edge_wire(harness, cable, connection) -> (str, str, str):
if connection.via.color:
# check if it's an actual wire and not a shield
wire_color = get_color_hex(connection.via.color, pad=harness.options._pad)
color = ":".join(["#000000"] + wire_color + ["#000000"])
else: # it's a shield connection
# shield is shown with specified color and black borders, or as a thin black wire otherwise
if connection.via.color:
shield_color_hex = get_color_hex(connection.via.color)[0]
shield_color_str = ":".join(["#000000", shield_color_hex, "#000000"])
else:
shield_color_str = "#000000"
color = shield_color_str
if connection.from_ is not None: # connect to left
from_port_str = (
f":p{connection.from_.index+1}r"
if harness.connectors[connection.from_.parent].style != "simple"
else ""
)
code_left_1 = f"{connection.from_.parent}{from_port_str}:e"
code_left_2 = f"{connection.via.parent}:w{connection.via.index+1}:w"
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
else:
code_left_1, code_left_2 = None, None
if connection.to is not None: # connect to right
to_port_str = (
f":p{connection.to.index+1}l"
if harness.connectors[connection.from_.parent].style != "simple"
else ""
)
code_right_1 = f"{connection.via.parent}:w{connection.via.index+1}:e"
code_right_2 = f"{connection.to.parent}{to_port_str}:w"
else:
code_right_1, code_right_2 = None, None
return color, code_left_1, code_left_2, code_right_1, code_right_2
def colored_cell(contents, bgcolor) -> Td:
return Td(contents, bgcolor=translate_color(bgcolor, "HEX"))
def part_number_str_list(component: Component) -> List[str]:
cell_contents = [
pn_info_string(HEADER_PN, None, component.pn),
pn_info_string(HEADER_MPN, component.manufacturer, component.mpn),
pn_info_string(HEADER_SPN, component.supplier, component.spn),
]
if any(cell_contents):
return [html_line_breaks(cell) for cell in cell_contents]
else:
return None
def colorbar_cell(color) -> Td:
if color:
return Td("", bgcolor=translate_color(color, "HEX"), width=4)
else:
return None
def image_and_caption_cells(component: Component) -> (Td, Td):
if not component.image:
return (None, None)
image_tag = Img(scale=component.image.scale, src=component.image.src)
image_cell_inner = Td(image_tag, flat=True)
if component.image.fixedsize:
# further nest the image in a table with width/height/fixedsize parameters, and place that table in a cell
image_cell_inner.update_attribs(**html_size_attr_dict(component.image))
image_cell = Td(
Table(Tr(image_cell_inner), border=0, cellspacing=0, cellborder=0, id="!")
)
else:
image_cell = image_cell_inner
image_cell.update_attribs(
balign="left",
bgcolor=translate_color(component.image.bgcolor, "HEX"),
sides="TLR" if component.image.caption else None,
)
if component.image.caption:
caption_cell = Td(
f"{html_line_breaks(component.image.caption)}", balign="left", sides="BLR"
)
else:
caption_cell = None
return (image_cell, caption_cell)
def html_size_attr_dict(image):
# Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object
from wireviz.DataClasses import Image
attr_dict = {}
if image:
if image.width:
attr_dict["width"] = image.width
if image.height:
attr_dict["height"] = image.height
if image.fixedsize:
attr_dict["fixedsize"] = "true"
return attr_dict
def html_line_breaks(inp):
return remove_links(inp).replace("\n", "
") if isinstance(inp, str) else inp
def set_dot_basics(dot, options):
dot.body.append(f"// Graph generated by {APP_NAME} {__version__}\n")
dot.body.append(f"// {APP_URL}\n")
dot.attr(
"graph",
rankdir="LR",
ranksep="2",
bgcolor=translate_color(options.bgcolor, "HEX"),
nodesep="0.33",
fontname=options.fontname,
)
dot.attr(
"node",
shape="none",
width="0",
height="0",
margin="0", # Actual size of the node is entirely determined by the label.
style="filled",
fillcolor=translate_color(options.bgcolor_node, "HEX"),
fontname=options.fontname,
)
dot.attr("edge", style="bold", fontname=options.fontname)
def apply_dot_tweaks(dot, tweak):
def typecheck(name: str, value: Any, expect: type) -> None:
if not isinstance(value, expect):
raise Exception(
f"Unexpected value type of {name}: Expected {expect}, got {type(value)}\n{value}"
)
# TODO?: Differ between override attributes and HTML?
if tweak.override is not None:
typecheck("tweak.override", tweak.override, dict)
for k, d in tweak.override.items():
typecheck(f"tweak.override.{k} key", k, str)
typecheck(f"tweak.override.{k} value", d, dict)
for a, v in d.items():
typecheck(f"tweak.override.{k}.{a} key", a, str)
typecheck(f"tweak.override.{k}.{a} value", v, (str, type(None)))
# Override generated attributes of selected entries matching tweak.override.
for i, entry in enumerate(dot.body):
if not isinstance(entry, str):
continue
# Find a possibly quoted keyword after leading TAB(s) and followed by [ ].
match = re.match(r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S)
keyword = match and match[2]
if not keyword in tweak.override.keys():
continue
for attr, value in tweak.override[keyword].items():
if value is None:
entry, n_subs = re.subn(
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
)
if n_subs < 1:
print(
f"Harness.create_graph() warning: {attr} not found in {keyword}!"
)
elif n_subs > 1:
print(
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!"
)
continue
if len(value) == 0 or " " in value:
value = value.replace('"', r"\"")
value = f'"{value}"'
entry, n_subs = re.subn(
f'{attr}=("[^"]*"|[^] ]*)', f"{attr}={value}", entry
)
if n_subs < 1:
# If attr not found, then append it
entry = re.sub(r"\]$", f" {attr}={value}]", entry)
elif n_subs > 1:
print(
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!"
)
dot.body[i] = entry
if tweak.append is not None:
if isinstance(tweak.append, list):
for i, element in enumerate(tweak.append, 1):
typecheck(f"tweak.append[{i}]", element, str)
dot.body.extend(tweak.append)
else:
typecheck("tweak.append", tweak.append, str)
dot.body.append(tweak.append)