Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Define a new dataclass with color and font attributes #255

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 84 additions & 25 deletions src/wireviz/DataClasses.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
# -*- coding: utf-8 -*-

from typing import Dict, List, Optional, Tuple, Union
from dataclasses import dataclass, field, InitVar
from dataclasses import asdict, dataclass, field, InitVar
from pathlib import Path

from wireviz.wv_helper import int2tuple, aspect_ratio
from wireviz.wv_colors import Color, Colors, ColorMode, ColorScheme, COLOR_CODES
from wireviz.wv_colors import Color, Colors, ColorMode, ColorScheme, COLOR_CODES, translate_color


# Each type alias have their legal values described in comments - validation might be implemented in the future
PlainText = str # Text not containing HTML tags nor newlines
Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output
MultilineHypertext = str # Hypertext possibly also including newlines to break lines in diagram output
Designator = PlainText # Case insensitive unique name of connector or cable
Points = float # Size in points = 1/72 inch

# Literal type aliases below are commented to avoid requiring python 3.8
ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']
Expand All @@ -33,25 +34,68 @@ class Metadata(dict):


@dataclass
class Options:
fontname: PlainText = 'arial'
bgcolor: Color = 'WH'
bgcolor_node: Optional[Color] = 'WH'
bgcolor_connector: Optional[Color] = None
bgcolor_cable: Optional[Color] = None
bgcolor_bundle: Optional[Color] = None
class Look:
"""Colors and font that defines how an element should look like."""
bordercolor: Optional[Color] = None
bgcolor: Optional[Color] = None
fontcolor: Optional[Color] = None
fontname: Optional[PlainText] = None
fontsize: Optional[Points] = None

def lookdict(self) -> dict:
"""Return Look attributes as dict."""
return {k:v for k,v in asdict(self).items() if k in asdict(DEFAULT_LOOK).keys()}

def _2dict(self) -> dict:
"""Return dict of non-None strings with color values translated to hex."""
return {
k:translate_color(v, "hex") if 'color' in k else str(v)
for k,v in self.lookdict().items() if v is not None
}

def graph_args(self) -> dict:
"""Return dict with arguments to a dot graph."""
return {k:v for k,v in self._2dict().items() if k != 'bordercolor'}

def node_args(self) -> dict:
"""Return dict with arguments to a dot node with filled style."""
return {k.replace('border', '').replace('bg', 'fill'):v for k,v in self._2dict().items()}

def html_style(self, color_prefix: Optional[str] = None, include_all: bool = True) -> str:
"""Return HTML style value containing all non-empty option values."""
translated = Look(**self._2dict())
return ' '.join(value for value in (
f'{color_prefix} {translated.bordercolor};' if self.bordercolor and color_prefix else None,
f'background-color: {translated.bgcolor};' if self.bgcolor and include_all else None,
f'color: {translated.fontcolor};' if self.fontcolor and include_all else None,
f'font-family: {self.fontname};' if self.fontname and include_all else None,
f'font-size: {self.fontsize}pt;' if self.fontsize and include_all else None,
) if value)

DEFAULT_LOOK = Look(
bordercolor = 'BK',
bgcolor = 'WH',
fontcolor = 'BK',
fontname = 'arial',
fontsize = 14,
)


@dataclass
class Options(Look):
node: Look = field(default_factory=dict)
connector: Look = field(default_factory=dict)
cable: Look = field(default_factory=dict)
bundle: Look = field(default_factory=dict)
color_mode: ColorMode = 'SHORT'
mini_bom_mode: bool = True

def __post_init__(self):
if not self.bgcolor_node:
self.bgcolor_node = self.bgcolor
if not self.bgcolor_connector:
self.bgcolor_connector = self.bgcolor_node
if not self.bgcolor_cable:
self.bgcolor_cable = self.bgcolor_node
if not self.bgcolor_bundle:
self.bgcolor_bundle = self.bgcolor_cable
# Build initialization dicts with default values followed by dict entries from YAML input.
self.node = Look(**{**self.lookdict(), **self.node})
self.connector = Look(**{**asdict(self.node), **self.connector})
self.cable = Look(**{**asdict(self.node), **self.cable})
self.bundle = Look(**{**asdict(self.cable), **self.bundle})


@dataclass
Expand All @@ -67,16 +111,19 @@ class Image:
src: str
scale: Optional[ImageScale] = None
# Attributes of the image cell <td> containing the image:
width: Optional[int] = None
height: Optional[int] = None
width: Optional[Points] = None
height: Optional[Points] = None
fixedsize: Optional[bool] = None
bgcolor: Optional[Color] = None
box: Optional[Look] = None
# Contents of the text cell <td> just below the image cell:
caption: Optional[MultilineHypertext] = None
# See also HTML doc at https://graphviz.org/doc/info/shapes.html#html

def __post_init__(self, gv_dir):

if isinstance(self.box, dict):
self.box = Look(**self.box)

if self.fixedsize is None:
# Default True if any dimension specified unless self.scale also is specified.
self.fixedsize = (self.width or self.height) and self.scale is None
Expand Down Expand Up @@ -109,7 +156,11 @@ class AdditionalComponent:
qty: float = 1
unit: Optional[str] = None
qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None
bgcolor: Optional[Color] = None
box: Optional[Look] = None

def __post_init__(self) -> None:
if isinstance(self.box, dict):
self.box = Look(**self.box)

@property
def description(self) -> str:
Expand All @@ -119,8 +170,8 @@ def description(self) -> str:
@dataclass
class Connector:
name: Designator
bgcolor: Optional[Color] = None
bgcolor_title: Optional[Color] = None
box: Optional[Look] = None
title: Optional[Look] = None
manufacturer: Optional[MultilineHypertext] = None
mpn: Optional[MultilineHypertext] = None
supplier: Optional[MultilineHypertext] = None
Expand All @@ -147,6 +198,10 @@ class Connector:

def __post_init__(self) -> None:

if isinstance(self.box, dict):
self.box = Look(**self.box)
if isinstance(self.title, dict):
self.title = Look(**self.title)
if isinstance(self.image, dict):
self.image = Image(**self.image)

Expand Down Expand Up @@ -205,8 +260,8 @@ def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> i
@dataclass
class Cable:
name: Designator
bgcolor: Optional[Color] = None
bgcolor_title: Optional[Color] = None
box: Optional[Look] = None
title: Optional[Look] = None
manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None
mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None
supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None
Expand Down Expand Up @@ -235,6 +290,10 @@ class Cable:

def __post_init__(self) -> None:

if isinstance(self.box, dict):
self.box = Look(**self.box)
if isinstance(self.title, dict):
self.title = Look(**self.title)
if isinstance(self.image, dict):
self.image = Image(**self.image)

Expand Down
48 changes: 24 additions & 24 deletions src/wireviz/Harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from wireviz.DataClasses import Metadata, Options, Tweak, Connector, Cable
from wireviz.wv_colors import get_color_hex, translate_color
from wireviz.wv_gv_html import nested_html_table, \
html_bgcolor_attr, html_bgcolor, html_colorbar, \
html_cell, html_colorbar, \
html_image, html_caption, remove_links, html_line_breaks
from wireviz.wv_bom import pn_info_string, component_table_entry, \
get_additional_component_table, bom_list, generate_bom, \
Expand Down Expand Up @@ -97,17 +97,16 @@ def create_graph(self) -> Graph:
dot.body.append(f'// {APP_URL}')
dot.attr('graph', rankdir='LR',
ranksep='2',
bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"),
nodesep='0.33',
fontname=self.options.fontname)
dot.attr('node',
shape='none',
width='0', height='0', margin='0', # Actual size of the node is entirely determined by the label.
**self.options.graph_args())
dot.attr('node', shape='none',
style='filled',
fillcolor=wv_colors.translate_color(self.options.bgcolor_node, "HEX"),
fontname=self.options.fontname)
width='0', height='0', margin='0', # Actual size of the node is entirely determined by the label.
**self.options.node.node_args())
dot.attr('edge', style='bold',
fontname=self.options.fontname)
**self.options.node_args())

wire_border_hex = wv_colors.get_color_hex(self.options.bordercolor)[0]

# prepare ports on connectors depending on which side they will connect
for _, cable in self.cables.items():
Expand All @@ -125,7 +124,7 @@ def create_graph(self) -> Graph:

html = []

rows = [[f'{html_bgcolor(connector.bgcolor_title)}{remove_links(connector.name)}'
rows = [[html_cell(connector.title, remove_links(connector.name))
if connector.show_name else None],
[pn_info_string(HEADER_PN, None, remove_links(connector.pn)),
html_line_breaks(pn_info_string(HEADER_MPN, connector.manufacturer, connector.mpn)),
Expand All @@ -140,7 +139,7 @@ def create_graph(self) -> Graph:
[html_caption(connector.image)]]
rows.extend(get_additional_component_table(self, connector))
rows.append([html_line_breaks(connector.notes)])
html.extend(nested_html_table(rows, html_bgcolor_attr(connector.bgcolor)))
html.extend(nested_html_table(rows, connector.box))

if connector.style != 'simple':
pinhtml = []
Expand Down Expand Up @@ -175,10 +174,11 @@ def create_graph(self) -> Graph:

html = '\n'.join(html)
dot.node(connector.name, label=f'<\n{html}\n>', shape='box', style='filled',
fillcolor=translate_color(self.options.bgcolor_connector, "HEX"))
**self.options.connector.node_args())

if len(connector.loops) > 0:
dot.attr('edge', color='#000000:#ffffff:#000000')
# TODO: Use self.options.wire.color and self.options.wire.bgcolor here?
dot.attr('edge', color=f'{wire_border_hex}:#ffffff:{wire_border_hex}')
if connector.ports_left:
loop_side = 'l'
loop_dir = 'w'
Expand Down Expand Up @@ -210,7 +210,7 @@ def create_graph(self) -> Graph:
elif cable.gauge_unit.upper() == 'AWG':
awg_fmt = f' ({mm2_equiv(cable.gauge)} mm\u00B2)'

rows = [[f'{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}'
rows = [[html_cell(cable.title, remove_links(cable.name))
if cable.show_name else None],
[pn_info_string(HEADER_PN, None,
remove_links(cable.pn)) if not isinstance(cable.pn, list) else None,
Expand All @@ -233,7 +233,7 @@ def create_graph(self) -> Graph:

rows.extend(get_additional_component_table(self, cable))
rows.append([html_line_breaks(cable.notes)])
html.extend(nested_html_table(rows, html_bgcolor_attr(cable.bgcolor)))
html.extend(nested_html_table(rows, cable.box))

wirehtml = []
wirehtml.append('<table border="0" cellspacing="0" cellborder="0">') # conductor table
Expand All @@ -258,10 +258,11 @@ def create_graph(self) -> Graph:
wirehtml.append(f' <td><!-- {i}_out --></td>')
wirehtml.append(' </tr>')

bgcolors = ['#000000'] + get_color_hex(connection_color, pad=pad) + ['#000000']
bgcolors = [wire_border_hex] + get_color_hex(connection_color, pad=pad) + [wire_border_hex]
wirehtml.append(f' <tr>')
wirehtml.append(f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">')
wirehtml.append(' <table cellspacing="0" cellborder="0" border="0">')
# TODO: Reverse curved wire colors instead? Test also with empty wire colors! wv_colors.default_color ??
for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>')
wirehtml.append(' </table>')
Expand Down Expand Up @@ -301,10 +302,10 @@ def create_graph(self) -> Graph:
if isinstance(cable.shield, str):
# shield is shown with specified color and black borders
shield_color_hex = wv_colors.get_color_hex(cable.shield)[0]
attributes = f'height="6" bgcolor="{shield_color_hex}" border="2" sides="tb"'
attributes = f'height="6" bgcolor="{shield_color_hex}" color="{wire_border_hex}" border="2" sides="tb"'
else:
# shield is shown as a thin black wire
attributes = f'height="2" bgcolor="#000000" border="0"'
attributes = f'height="2" bgcolor="{wire_border_hex}" border="0"'
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" {attributes} port="ws"></td></tr>')

wirehtml.append(' <tr><td>&nbsp;</td></tr>')
Expand All @@ -315,10 +316,10 @@ def create_graph(self) -> Graph:
# connections
for connection in cable.connections:
if isinstance(connection.via_port, int): # check if it's an actual wire and not a shield
dot.attr('edge', color=':'.join(['#000000'] + wv_colors.get_color_hex(cable.colors[connection.via_port - 1], pad=pad) + ['#000000']))
dot.attr('edge', color=':'.join([wire_border_hex] + wv_colors.get_color_hex(cable.colors[connection.via_port - 1], pad=pad) + [wire_border_hex]))
else: # it's a shield connection
# shield is shown with specified color and black borders, or as a thin black wire otherwise
dot.attr('edge', color=':'.join(['#000000', shield_color_hex, '#000000']) if isinstance(cable.shield, str) else '#000000')
dot.attr('edge', color=':'.join([wire_border_hex, shield_color_hex, wire_border_hex]) if isinstance(cable.shield, str) else wire_border_hex)
if connection.from_port is not None: # connect to left
from_connector = self.connectors[connection.from_name]
from_port = f':p{connection.from_port+1}r' if from_connector.style != 'simple' else ''
Expand Down Expand Up @@ -352,11 +353,10 @@ def create_graph(self) -> Graph:
to_string = ''
html = [row.replace(f'<!-- {connection.via_port}_out -->', to_string) for row in html]

style, bgcolor = ('filled,dashed', self.options.bgcolor_bundle) if cable.category == 'bundle' else \
('filled', self.options.bgcolor_cable)
style, options = ('filled,dashed', self.options.bundle) if cable.category == 'bundle' else \
('filled', self.options.cable)
html = '\n'.join(html)
dot.node(cable.name, label=f'<\n{html}\n>', shape='box',
style=style, fillcolor=translate_color(bgcolor, "HEX"))
dot.node(cable.name, label=f'<\n{html}\n>', shape='box', style=style, **options.node_args())

def typecheck(name: str, value: Any, expect: type) -> None:
if not isinstance(value, expect):
Expand Down
5 changes: 3 additions & 2 deletions src/wireviz/wireviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-

import argparse
from dataclasses import asdict
import os
from pathlib import Path
import sys
Expand All @@ -13,7 +14,7 @@
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from wireviz import __version__
from wireviz.DataClasses import Metadata, Options, Tweak
from wireviz.DataClasses import DEFAULT_LOOK, Metadata, Options, Tweak
from wireviz.Harness import Harness
from wireviz.wv_helper import expand, open_file_read

Expand All @@ -37,7 +38,7 @@ def parse(yaml_input: str, file_out: (str, Path) = None, return_types: (None, st

harness = Harness(
metadata = Metadata(**yaml_data.get('metadata', {})),
options = Options(**yaml_data.get('options', {})),
options = Options(**asdict(DEFAULT_LOOK), **yaml_data.get('options', {})),
tweak = Tweak(**yaml_data.get('tweak', {})),
)
if 'title' not in harness.metadata:
Expand Down
14 changes: 7 additions & 7 deletions src/wireviz/wv_bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from itertools import groupby
from typing import Any, Dict, List, Optional, Tuple, Union

from wireviz.DataClasses import AdditionalComponent, Cable, Color, Connector
from wireviz.wv_colors import translate_color
from wireviz.wv_gv_html import html_bgcolor_attr, html_line_breaks
from wireviz.DataClasses import AdditionalComponent, Cable, Connector, Look
from wireviz.wv_colors import Color, translate_color
from wireviz.wv_gv_html import font_tag, html_line_breaks, table_attr
from wireviz.wv_helper import clean_whitespace

BOM_COLUMNS_ALWAYS = ('id', 'description', 'qty', 'unit', 'designators')
Expand Down Expand Up @@ -35,7 +35,7 @@ def get_additional_component_table(harness: "Harness", component: Union[Connecto
common_args = {
'qty': part.qty * component.get_qty_multiplier(part.qty_multiplier),
'unit': part.unit,
'bgcolor': part.bgcolor,
'box': part.box,
}
if harness.options.mini_bom_mode:
id = get_bom_index(harness.bom(), bom_entry_key({**asdict(part), 'description': part.description}))
Expand Down Expand Up @@ -158,7 +158,7 @@ def component_table_entry(
type: str,
qty: Union[int, float],
unit: Optional[str] = None,
bgcolor: Optional[Color] = None,
box: Optional[Look] = None,
pn: Optional[str] = None,
manufacturer: Optional[str] = None,
mpn: Optional[str] = None,
Expand All @@ -178,8 +178,8 @@ def component_table_entry(
+ (', '.join([pn for pn in part_number_list if pn])))
# format the above output as left aligned text in a single visible cell
# indent is set to two to match the indent in the generated html table
return f'''<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{html_bgcolor_attr(bgcolor)}><tr>
<td align="left" balign="left">{html_line_breaks(output)}</td>
return f'''<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{table_attr(box)}><tr>
<td align="left" balign="left">{font_tag(box, html_line_breaks(output))}</td>
</tr></table>'''

def pn_info_string(header: str, name: Optional[str], number: Optional[str]) -> Optional[str]:
Expand Down
Loading