Compare commits

...

3 Commits

Author SHA1 Message Date
5f54ab1f0d Add strip length specs for connectors (port of upstream PR #446)
Some checks are pending
Create Examples / build (ubuntu-22.04, 3.7) (push) Waiting to run
Create Examples / build (ubuntu-22.04, 3.8) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.10) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.11) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.12) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.9) (push) Waiting to run
Connectors now support a 'strip' property with sleeve and insulation
length specifications, rendered in the diagram node and available in
the data model for BOM/manufacturing use.
2026-02-13 00:29:09 -07:00
c84e6fb3ad Embed YAML source in PNG metadata for round-trip capability (port of upstream PR #234)
PNGs now contain the original YAML harness definition as compressed
iTXT metadata. Use read_yaml_from_png() to extract it — share a PNG,
recipient can regenerate or edit the harness.
2026-02-13 00:27:09 -07:00
d8260b3fde Enable PDF output format (port of upstream PR #367)
GraphViz natively supports PDF rendering, so this just adds "pdf" to
the allowed format list and exposes it via the -f P CLI flag.
2026-02-13 00:25:29 -07:00
6 changed files with 127 additions and 9 deletions

View File

@ -14,6 +14,7 @@ if __name__ == "__main__":
from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
from wireviz.wv_harness import Harness
from wireviz.wv_png_metadata import save_yaml_to_png
from wireviz.wv_utils import (
expand,
file_read_text,
@ -88,7 +89,7 @@ def parse(
if not output_formats and not return_types:
raise Exception("No output formats or return types specified")
yaml_data, yaml_file = _get_yaml_data_and_path(inp)
yaml_data, yaml_file, yaml_str = _get_yaml_data_and_path(inp)
if not isinstance(yaml_data, dict):
raise TypeError(
f"Expected a dict as top-level YAML input, but got: {type(yaml_data)}"
@ -394,6 +395,9 @@ def parse(
if output_formats:
harness.output(filename=output_file, fmt=output_formats, view=False)
# embed YAML source into PNG for round-trip capability
if "png" in output_formats and yaml_str:
save_yaml_to_png(output_file, yaml_str)
if return_types:
returns = []
@ -413,7 +417,7 @@ def parse(
return tuple(returns) if len(returns) != 1 else returns[0]
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> Tuple[Dict, Path]:
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> Tuple[Dict, Path, str]:
# determine whether inp is a file path, a YAML string, or a Dict
if not isinstance(inp, Dict): # received a str or a Path
try:
@ -441,7 +445,8 @@ def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> Tuple[Dict, Path]:
# received a Dict, use as-is
yaml_data = inp
yaml_path = None
return yaml_data, yaml_path
yaml_str = None
return yaml_data, yaml_path, yaml_str
def _get_output_dir(input_file: Path, default_output_dir: Path) -> Path:

View File

@ -19,7 +19,7 @@ format_codes = {
"g": "gv",
"h": "html",
"p": "png",
# "P": "pdf", # TODO: support PDF
"P": "pdf",
"s": "svg",
"t": "tsv",
}

View File

@ -293,6 +293,42 @@ class AdditionalComponent(GraphicalComponent):
self.explicit_qty = False
@dataclass
class StripSpec:
"""Strip length specification for sleeve or insulation."""
length: float = 0
length_unit: str = "mm"
def __str__(self) -> str:
return f"{self.length} {self.length_unit}" if self.length > 0 else ""
@dataclass
class Strip:
"""Wire stripping specifications for a connector."""
sleeve: Optional[StripSpec] = None
insulation: Optional[StripSpec] = None
def __post_init__(self) -> None:
if isinstance(self.sleeve, str):
self.sleeve = Strip._parse_length(self.sleeve)
if isinstance(self.insulation, str):
self.insulation = Strip._parse_length(self.insulation)
@staticmethod
def _parse_length(spec: str) -> StripSpec:
parts = spec.strip().split(" ", 1)
try:
length = float(parts[0])
except (ValueError, IndexError):
raise Exception(
f"Strip length '{spec}' must be a number, "
"or number and unit separated by a space"
)
unit = parts[1].strip() if len(parts) > 1 else "mm"
return StripSpec(length=length, length_unit=unit)
@dataclass
class TopLevelGraphicalComponent(GraphicalComponent): # abstract class
# component properties
@ -317,6 +353,7 @@ class Connector(TopLevelGraphicalComponent):
shorts_hide_lable: bool = False
# pin information in particular
pincount: Optional[int] = None
strip: Optional[Strip] = None
pins: List[Pin] = field(default_factory=list) # legacy
pinlabels: List[Pin] = field(default_factory=list) # legacy
pincolors: List[str] = field(default_factory=list) # legacy
@ -367,6 +404,11 @@ class Connector(TopLevelGraphicalComponent):
if isinstance(self.image, dict):
self.image = Image(**self.image)
if isinstance(self.strip, dict):
self.strip = Strip(**self.strip)
elif self.strip is None:
self.strip = Strip()
self.ports_left = False
self.ports_right = False
self.visible_pins = {}

View File

@ -63,6 +63,16 @@ def gv_node_component(component: Component) -> Table:
str(component.color) if component.color else None,
]
# strip info for connectors
line_strip = None
if isinstance(component, Connector) and (component.strip.sleeve or component.strip.insulation):
strip_parts = []
if component.strip.sleeve:
strip_parts.append(f"Strip sleeve: {component.strip.sleeve}")
if component.strip.insulation:
strip_parts.append(f"Strip insulation: {component.strip.insulation}")
line_strip = [Td(" | ".join(strip_parts))]
if component.additional_parameters:
line_additional_parameters = nested_table_dict(component.additional_parameters)
else:
@ -87,6 +97,7 @@ def gv_node_component(component: Component) -> Table:
line_name,
line_pn,
line_info,
line_strip,
line_additional_parameters,
line_ports,
line_image,

View File

@ -440,7 +440,7 @@ class Harness:
# graphical output
graph = self.graph
for f in fmt:
if f in ("png", "svg", "html"):
if f in ("png", "svg", "html", "pdf"):
if f == "html": # if HTML format is specified,
f = "svg" # generate SVG for embedding into HTML
# SVG file will be renamed/deleted later
@ -471,10 +471,7 @@ class Harness:
# HTML output
if "html" in fmt:
generate_html_output(filename, bomlist, self.metadata, self.options)
# PDF output
if "pdf" in fmt:
# TODO: implement PDF output
print("PDF output is not yet supported")
# PDF output is handled by GraphViz in the format loop above
# delete SVG if not needed
if "html" in fmt and not "svg" in fmt:
# SVG file was just needed to generate HTML

View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Embed and extract YAML source data in PNG metadata (iTXT chunks)."""
from pathlib import Path
from typing import Optional, Tuple
from PIL import Image
from PIL.PngImagePlugin import PngInfo
PNG_KEY_YAML = "wireviz_yaml"
PNG_KEY_PREPEND = "wireviz_prepend_yaml"
def save_yaml_to_png(
png_path: Path,
yaml_input: str,
prepend_input: str = "",
) -> None:
"""Save YAML source as compressed iTXT metadata in a PNG file."""
png_path = Path(png_path)
if not png_path.suffix == ".png":
png_path = png_path.with_suffix(".png")
if not png_path.exists():
return
with Image.open(fp=png_path) as im:
txt = PngInfo()
txt.add_itxt(PNG_KEY_YAML, yaml_input, zip=True)
if prepend_input:
txt.add_itxt(PNG_KEY_PREPEND, prepend_input, zip=True)
im.save(fp=png_path, pnginfo=txt)
def read_yaml_from_png(png_path: Path) -> Tuple[str, Optional[str]]:
"""Extract YAML source from a PNG file's iTXT metadata.
Returns (yaml_input, prepend_input) where prepend_input may be None.
"""
png_path = Path(png_path)
if not png_path.suffix == ".png":
png_path = png_path.with_suffix(".png")
with Image.open(fp=png_path) as im:
im.load()
yaml_input = im.text.get(PNG_KEY_YAML, "")
prepend_input = im.text.get(PNG_KEY_PREPEND)
return yaml_input, prepend_input
def has_yaml_metadata(png_path: Path) -> bool:
"""Check if a PNG file contains embedded WireViz YAML data."""
png_path = Path(png_path)
if not png_path.suffix == ".png":
png_path = png_path.with_suffix(".png")
if not png_path.exists():
return False
with Image.open(fp=png_path) as im:
im.load()
return PNG_KEY_YAML in im.text