diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 6b7462ab..6da26b6e 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -1,11 +1,11 @@ # -*- 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 @@ -13,6 +13,7 @@ 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'] @@ -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 @@ -67,16 +111,19 @@ class Image: src: str scale: Optional[ImageScale] = None # Attributes of the image cell 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 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 @@ -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: @@ -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 @@ -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) @@ -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 @@ -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) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 2f9eb641..4812986c 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -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, \ @@ -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(): @@ -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)), @@ -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 = [] @@ -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' @@ -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, @@ -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('') # conductor table @@ -258,10 +258,11 @@ def create_graph(self) -> Graph: wirehtml.append(f' ') wirehtml.append(' ') - 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' ') wirehtml.append(f' ') wirehtml.append(' ') @@ -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 '' @@ -352,11 +353,10 @@ def create_graph(self) -> Graph: to_string = '' html = [row.replace(f'', 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): diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 95674945..27161695 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import argparse +from dataclasses import asdict import os from pathlib import Path import sys @@ -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 @@ -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: diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index 56df752d..b0fa3878 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -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') @@ -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})) @@ -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, @@ -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'''
') wirehtml.append(' ') + # 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' ') wirehtml.append('
') @@ -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'
 
- + return f'''
{html_line_breaks(output)}
+
{font_tag(box, html_line_breaks(output))}
''' def pn_info_string(header: str, name: Optional[str], number: Optional[str]) -> Optional[str]: diff --git a/src/wireviz/wv_gv_html.py b/src/wireviz/wv_gv_html.py index 0b843db5..4cc99533 100644 --- a/src/wireviz/wv_gv_html.py +++ b/src/wireviz/wv_gv_html.py @@ -1,19 +1,24 @@ # -*- coding: utf-8 -*- from typing import List, Optional, Union -import re -from wireviz.DataClasses import Color -from wireviz.wv_colors import translate_color +from wireviz.DataClasses import Image, Look +from wireviz.wv_colors import Color, translate_color from wireviz.wv_helper import remove_links -def nested_html_table(rows: List[Union[str, List[Optional[str]], None]], table_attrs: str = '') -> str: - # input: list, each item may be scalar or list +GvHtml = str # Graphviz HTML-like label string +GvHtmlX = str # Graphviz HTML-like label string possibly including a leading tag +GvHtmlAttr = str # Attributes part of Graphviz HTML-like tag (including a leading space) + +def nested_html_table(rows: List[Union[GvHtml, List[Optional[GvHtmlX]], None]], look: Optional[Look]) -> GvHtml: + # input: list, each item may be scalar or list, and look with optional table look attributes # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar # purpose: create the appearance of one table, where cell widths are independent between rows # attributes in any leading inside a list are injected into to the preceeding tag html = [] - html.append(f'') + attr = font_attr(look) + font = f'' if attr else '' + html.append(f'{font}
') for row in rows: if isinstance(row, List): if len(row) > 0 and any(row): @@ -29,23 +34,36 @@ def nested_html_table(rows: List[Union[str, List[Optional[str]], None]], table_a html.append(' ') - html.append('
') html.append(f' {row}') html.append('
') + html.append(f'{"" if font else ""}') return html -def html_bgcolor_attr(color: Color) -> str: - """Return attributes for bgcolor or '' if no color.""" - return f' bgcolor="{translate_color(color, "HEX")}"' if color else '' +def table_attr(look: Optional[Look]) -> GvHtmlAttr: + """Return table tag attributes containing all non-empty table option values.""" + return '' if not look else ''.join({ + f' {k.replace("border", "")}="{v}"' for k,v in look._2dict().items() if v and 'font' not in k}) + +def font_attr(look: Optional[Look]) -> GvHtmlAttr: + """Return font tag attributes containing all non-empty font option values.""" + attr = {k:v for k,v in look._2dict().items() if v and 'font' in k} if look else {} + return ((f' color="{attr["fontcolor"]}"' if attr.get('fontcolor') else '') + + (f' face="{attr["fontname"]}"' if attr.get('fontname') else '') + + (f' point-size="{attr["fontsize"]}"' if attr.get('fontsize') else '')) + +def font_tag(look: Optional[Look], text: GvHtml) -> GvHtml: + """Return text in Graphviz HTML font tag with all non-empty font option values.""" + attr = font_attr(look) + return f'{text}' if attr and text > '' else text -def html_bgcolor(color: Color, _extra_attr: str = '') -> str: - """Return attributes prefix for bgcolor or '' if no color.""" - return f'' if color else '' +def html_cell(look: Optional[Look], text: GvHtml = '', attr: GvHtmlAttr = '') -> GvHtmlX: + """Return cell to be included in the rows list for nested_html_table().""" + return f'{font_tag(look, text)}' -def html_colorbar(color: Color) -> str: - """Return attributes prefix for bgcolor and minimum width or None if no color.""" - return html_bgcolor(color, ' width="4"') if color else None +def html_colorbar(color: Optional[Color]) -> Optional[GvHtmlX]: + """Return colored cell to be included in the rows list for nested_html_table() or None if no color.""" + return html_cell(Look(bgcolor=color), attr=' width="4"') if color else None -def html_image(image): - from wireviz.DataClasses import Image +def html_image(image: Optional[Image]) -> Optional[GvHtmlX]: + """Return image cell to be included in the rows list for nested_html_table() or None if no image.""" if not image: return None # The leading attributes belong to the preceeding tag. See where used below. @@ -58,16 +76,14 @@ def html_image(image): ''' - return f'''{html_line_breaks(image.caption)}' - if image and image.caption else None) +def html_caption(image: Optional[Image]) -> Optional[GvHtmlX]: + """Return image caption cell to be included just after the image cell or None if no caption.""" + return html_cell(image.box, html_line_breaks(image.caption), ' sides="BLR"') if image and image.caption else None -def html_size_attr(image): - from wireviz.DataClasses import Image - # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object +def html_size_attr(image: Optional[Image]) -> GvHtmlAttr: + """Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object.""" return ((f' width="{image.width}"' if image.width else '') + (f' height="{image.height}"' if image.height else '') + ( ' fixedsize="true"' if image.fixedsize else '')) if image else '' diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 0b819746..d05ccfac 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -15,9 +15,7 @@ def generate_html_output(filename: Union[str, Path], bom_list: List[List[str]], file.write(' \n') file.write(f' \n') file.write(f' {metadata["title"]}\n') - file.write(f'\n') - + file.write(f'\n') file.write(f'

{metadata["title"]}

\n') description = metadata.get('description') if description: @@ -33,17 +31,18 @@ def generate_html_output(filename: Union[str, Path], bom_list: List[List[str]], file.write('

Bill of Materials

\n') listy = flatten2d(bom_list) - file.write('\n') + border = options.html_style(color_prefix="border: 1px solid", include_all=False) + file.write(f'
\n') file.write(' \n') for item in listy[0]: - file.write(f' \n') + file.write(f' \n') file.write(' \n') for row in listy[1:]: file.write(' \n') for i, item in enumerate(row): item_str = item.replace('\u00b2', '²') align = '; text-align:right' if listy[0][i] == 'Qty' else '' - file.write(f' \n') + file.write(f' \n') file.write(' \n') file.write('
{item}{item}
{item_str}{item_str}
\n')