From 4782da47c9060304d61c947a1a6b6ab27aa53bb8 Mon Sep 17 00:00:00 2001 From: kvid Date: Wed, 14 Oct 2020 16:08:16 +0200 Subject: [PATCH] Add optional image to connectors and cables (#153) This image, with an optional caption below, is displayed in the lower section of the connector/cable node in the diagram - just above the notes if present. This solves the basic part of issue #27, and is a continuation of PR #137 that was closed due to changes in the base branch. --- docs/advanced_image_usage.md | 61 +++++++++++++++++++ examples/ex08.yml | 11 +++- examples/resources/cable-WH+BN+GN+shield.png | Bin 0 -> 4449 bytes examples/resources/stereo-phone-plug-TRS.png | Bin 0 -> 1933 bytes requirements.txt | 1 + setup.py | 1 + src/wireviz/DataClasses.py | 50 ++++++++++++++- src/wireviz/Harness.py | 6 +- src/wireviz/wireviz.py | 5 ++ src/wireviz/wv_helper.py | 42 ++++++++++++- 10 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 docs/advanced_image_usage.md create mode 100644 examples/resources/cable-WH+BN+GN+shield.png create mode 100644 examples/resources/stereo-phone-plug-TRS.png diff --git a/docs/advanced_image_usage.md b/docs/advanced_image_usage.md new file mode 100644 index 0000000..5fd4288 --- /dev/null +++ b/docs/advanced_image_usage.md @@ -0,0 +1,61 @@ +# Advanced Image Usage + +In rare cases when the [ordinary image scaling functionality](syntax.md#images) is insufficient, a couple of extra optional image attributes can be set to offer extra image cell space and scaling functionality when combined with the image dimension attributes `width` and `height`, but in most cases their default values below are sufficient: +- `scale: ` (how an image will use the available cell space) is default `false` if no dimension is set, or `true` if only one dimension is set, or `both` if both dimensions are set. +- `fixedsize: ` (scale to fixed size or expand to minimum size) is default `false` when no dimension is set or if a `scale` value is set, and `true` otherwise. +- When `fixedsize` is true and only one dimension is set, then the other dimension is calculated using the image aspect ratio. If reading the aspect ratio fails, then 1:1 ratio is assumed. + +See explanations of all supported values for these attributes in subsections below. + +## The effect of `fixedsize` boolean values + +- When `false`, any `width` or `height` values are _minimum_ values used to expand the image cell size for more available space, but cell contents or other size demands in the table might expand this cell even more than specified by `width` or `height`. +- When `true`, both `width` and `height` values are required by Graphwiz and specify the fixed size of the image cell, distorting any image inside if it don't fit. Any borders are normally drawn around the fixed size, and therefore, WireViz enclose the image cell in an extra table without borders when `fixedsize` is true to keep the borders around the outer non-fixed cell. + +## The effect of `scale` string values: + +- When `false`, the image is not scaled. +- When `true`, the image is scaled proportionally to fit within the available image cell space. +- When `width`, the image width is expanded (height is normally unchanged) to fill the available image cell space width. +- When `height`, the image height is expanded (width is normally unchanged) to fill the available image cell space height. +- When `both`, both image width and height are expanded independently to fill the available image cell space. + +In all cases (except `true`) the image might get distorted when a specified fixed image cell size limits the available space to less than what an unscaled image needs. + +In the WireViz diagrams there are no other space demanding cells in the same row, and hence, there are never extra available image cell space height unless a greater image cell `height` also is set. + +## Usage examples + +All examples of `image` attribute combinations below also require the mandatory `src` attribute to be set. + +- Expand the image proportionally to fit within a minimum height and the node width: +```yaml + height: 100 # Expand image cell to this minimum height + fixedsize: false # Avoid scaling to a fixed size + # scale default value is true in this case +``` + +- Increase the space around the image by expanding the image cell space (width and/or height) to a larger value without scaling the image: +```yaml + width: 200 # Expand image cell to this minimum width + height: 100 # Expand image cell to this minimum height + scale: false # Avoid scaling the image + # fixedsize default value is false in this case +``` + +- Stretch the image width to fill the available space in the node: +```yaml + scale: width # Expand image width to fill the available image cell space + # fixedsize default value is false in this case +``` + +- Stretch the image height to a minimum value: +```yaml + height: 100 # Expand image cell to this minimum height + scale: height # Expand image height to fill the available image cell space + # fixedsize default value is false in this case +``` + +## How Graphviz support this image scaling + +The connector and cable nodes are rendered using a HTML `` containing an image cell `') elif row is not None: @@ -53,6 +55,30 @@ def nested_html_table(rows): html.append('
` with `width`, `height`, and `fixedsize` attributes containing an image `` with `src` and `scale` attributes. See also the [Graphviz doc](https://graphviz.org/doc/info/shapes.html#html), but note that WireViz uses default values as described above. diff --git a/examples/ex08.yml b/examples/ex08.yml index 2195c4a..eea3976 100644 --- a/examples/ex08.yml +++ b/examples/ex08.yml @@ -1,4 +1,5 @@ # contributed by @cocide +# and later extended to include images connectors: Key: @@ -7,14 +8,22 @@ connectors: pins: [T, R, S] pinlabels: [Dot, Dash, Ground] show_pincount: false + image: + src: resources/stereo-phone-plug-TRS.png + caption: Tip, Ring, and Sleeve cables: W1: gauge: 24 AWG length: 0.2 + color: BK # Cable jacket color color_code: DIN wirecount: 3 - shield: true + shield: SN # Matching the shield color in the image + image: + src: resources/cable-WH+BN+GN+shield.png + height: 70 # Scale the image size slightly down + caption: Cross-section connections: - diff --git a/examples/resources/cable-WH+BN+GN+shield.png b/examples/resources/cable-WH+BN+GN+shield.png new file mode 100644 index 0000000000000000000000000000000000000000..f854aeae4b491bc08185e34cd84f1084450c10d6 GIT binary patch literal 4449 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 9339481..92620ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ . graphviz +pillow pyyaml setuptools \ No newline at end of file diff --git a/setup.py b/setup.py index 1a49016..cd28974 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ setup( long_description_content_type='text/markdown', install_requires=[ 'pyyaml', + 'pillow', 'graphviz', ], license='GPLv3', diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 6b3b1e3..3ad84d2 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -2,11 +2,48 @@ # -*- coding: utf-8 -*- from typing import Optional, List, Any, Union -from dataclasses import dataclass, field -from wireviz.wv_helper import int2tuple +from dataclasses import dataclass, field, InitVar +from pathlib import Path +from wireviz.wv_helper import int2tuple, aspect_ratio from wireviz import wv_colors +@dataclass +class Image: + gv_dir: InitVar[Path] # Directory of .gv file injected as context during parsing + # Attributes of the image object : + src: str + scale: Optional[str] = None # false | true | width | height | both + # Attributes of the image cell containing the image: + width: Optional[int] = None + height: Optional[int] = None + fixedsize: Optional[bool] = None + # Contents of the text cell just below the image cell: + caption: Optional[str] = None + # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html + + def __post_init__(self, gv_dir): + + 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 + + if self.scale is None: + self.scale = "false" if not self.width and not self.height \ + else "both" if self.width and self.height \ + else "true" # When only one dimension is specified. + + if self.fixedsize: + # If only one dimension is specified, compute the other + # because Graphviz requires both when fixedsize=True. + if self.height: + if not self.width: + self.width = self.height * aspect_ratio(gv_dir.joinpath(self.src)) + else: + if self.width: + self.height = self.width / aspect_ratio(gv_dir.joinpath(self.src)) + + @dataclass class Connector: name: str @@ -18,6 +55,7 @@ class Connector: type: Optional[str] = None subtype: Optional[str] = None pincount: Optional[int] = None + image: Optional[Image] = None notes: Optional[str] = None pinlabels: List[Any] = field(default_factory=list) pins: List[Any] = field(default_factory=list) @@ -29,6 +67,10 @@ class Connector: loops: List[Any] = field(default_factory=list) def __post_init__(self): + + if isinstance(self.image, dict): + self.image = Image(**self.image) + self.ports_left = False self.ports_right = False self.visible_pins = {} @@ -91,6 +133,7 @@ class Cable: color: Optional[str] = None wirecount: Optional[int] = None shield: bool = False + image: Optional[Image] = None notes: Optional[str] = None colors: List[Any] = field(default_factory=list) color_code: Optional[str] = None @@ -99,6 +142,9 @@ class Cable: def __post_init__(self): + if isinstance(self.image, dict): + self.image = Image(**self.image) + if isinstance(self.gauge, str): # gauge and unit specified try: g, u = self.gauge.split(' ') diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index af7946e..e701425 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -8,7 +8,7 @@ from wireviz.wv_colors import get_color_hex from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, \ nested_html_table, flatten2d, index_if_list, html_line_breaks, \ graphviz_line_breaks, remove_line_breaks, open_file_read, open_file_write, \ - manufacturer_info_field + html_image, html_caption, manufacturer_info_field from collections import Counter from typing import List from pathlib import Path @@ -98,6 +98,8 @@ class Harness: f'{connector.pincount}-pin' if connector.show_pincount else None, connector.color, '' if connector.color else None], '' if connector.style != 'simple' else None, + [html_image(connector.image)], + [html_caption(connector.image)], [html_line_breaks(connector.notes)]] html.extend(nested_html_table(rows)) @@ -173,6 +175,8 @@ class Harness: f'{cable.length} m' if cable.length > 0 else None, cable.color, '' if cable.color else None], '', + [html_image(cable.image)], + [html_caption(cable.image)], [html_line_breaks(cable.notes)]] html.extend(nested_html_table(rows)) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index cac6aa0..9ecef5a 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -44,6 +44,11 @@ def parse(yaml_input: str, file_out: (str, Path) = None, return_types: (None, st if len(yaml_data[sec]) > 0: if ty == dict: for key, attribs in yaml_data[sec].items(): + # The Image dataclass might need to open an image file with a relative path. + image = attribs.get('image') + if isinstance(image, dict): + image['gv_dir'] = Path(file_out if file_out else '').parent # Inject context + if sec == 'connectors': if not attribs.get('autogenerate', False): harness.add_connector(name=key, **attribs) diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 418060d..32b8fb6 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -34,6 +34,7 @@ def nested_html_table(rows): # input: list, each item may be scalar or list # 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('') for row in rows: @@ -43,7 +44,8 @@ def nested_html_table(rows): html.append('
') for cell in row: if cell is not None: - html.append(f' ') + # Inject attributes to the preceeding '.replace('>
{cell} tag where needed + html.append(f' {cell}
') html.append('
') return html +def html_image(image): + if not image: + return None + # The leading attributes belong to the preceeding tag. See where used below. + html = f'{html_size_attr(image)}>' + if image.fixedsize: + # Close the preceeding tag and enclose the image cell in a table without + # borders to avoid narrow borders when the fixed width < the node width. + html = f'''> + + +
+ ''' + return f'''{html_line_breaks(image.caption)}' if image and image.caption else None + +def html_size_attr(image): + # 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 '' + def expand(yaml_data): # yaml_data can be: @@ -132,6 +158,20 @@ def open_file_write(filename): def open_file_append(filename): return open(filename, 'a', encoding='UTF-8') + +def aspect_ratio(image_src): + try: + from PIL import Image + image = Image.open(image_src) + if image.width > 0 and image.height > 0: + return image.width / image.height + print(f'aspect_ratio(): Invalid image size {image.width} x {image.height}') + # ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally. + except Exception as error: + print(f'aspect_ratio(): {type(error).__name__}: {error}') + return 1 # Assume 1:1 when unable to read actual image size + + def manufacturer_info_field(manufacturer, mpn): if manufacturer or mpn: return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}'