From a8abc2b2a0d593ee9c3fbcec08c1e37ca87a2281 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 19:59:01 -0400 Subject: [PATCH 01/11] Add --template-dir CLI option (port of upstream PR #444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users point WireViz at an explicit directory of HTML templates when resolving a metadata.template.name reference. Useful for shared branded chrome that lives outside both the YAML source tree and the output tree. CLI: wireviz -t ./brand-templates harness.yml Programmatic: wireviz.parse(yaml_str, output_formats=("html",), template_dir="./brand-templates", ...) The new explicit path is searched first, before the implicit ones already in place. Final lookup order: 1. --template-dir / parse template_dir (explicit) 2. YAML source directory (source_path.parent) (PR #473) 3. output directory (existing) 4. WireViz built-in templates (fallback) Adapted from https://github.com/wireviz/WireViz/pull/444 (originally by @tbornon-sts) — the upstream patch used inconsistent naming (``templatedir`` on the kwarg, ``template_dir`` on the CLI option) and included a leftover ``print("Test")`` debug statement; this port uses ``template_dir`` consistently and drops the debug line. Verified against build_examples.py (deterministic outputs unchanged) and against a manual case with a custom branded.html living only in the -t directory: template resolves correctly, and absence of -t produces a clean "was not found" error from smart_file_resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 4 ++++ src/wireviz/wireviz.py | 9 ++++++++- src/wireviz/wv_cli.py | 10 +++++++++- src/wireviz/wv_html.py | 11 ++++++++--- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 1efef9fc..294297af 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -678,6 +678,7 @@ def output( cleanup: bool = True, output_dir: Optional[Union[str, Path]] = None, output_name: Optional[str] = None, + template_dir: Optional[Union[str, Path]] = None, ) -> None: """Render the harness in the requested formats. @@ -693,6 +694,7 @@ def output( fmt, output_dir=output_dir, output_name=output_name, + template_dir=template_dir, ) if "csv" in fmt: @@ -728,6 +730,7 @@ def _render( fmt: Union[str, Tuple[str, ...], List[str]], output_dir: Optional[Union[str, Path]] = None, output_name: Optional[str] = None, + template_dir: Optional[Union[str, Path]] = None, ) -> Dict[str, Union[str, bytes]]: """Produce in-memory representations of each requested format. @@ -788,6 +791,7 @@ def _render( output_name=output_name, png_b64=png_b64, source_path=self.source_path, + template_dir=template_dir, ) return outputs diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index a4d20300..880eff63 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -32,6 +32,7 @@ def parse( output_name: Union[None, str] = None, image_paths: Union[Path, str, List] = [], source_path: Union[Path, str, None] = None, + template_dir: Union[Path, str, None] = None, ) -> Any: """ This function takes an input, parses it as a WireViz Harness file, @@ -421,7 +422,12 @@ def alternate_type(): # flip between connector and cable/arrow raise ValueError( "Exactly one output format must be specified when writing to stdout." ) - harness.output(filename=None, fmt=output_formats, view=False) + harness.output( + filename=None, + fmt=output_formats, + view=False, + template_dir=template_dir, + ) else: harness.output( filename=output_file, @@ -429,6 +435,7 @@ def alternate_type(): # flip between connector and cable/arrow view=False, output_dir=output_dir, output_name=output_name, + template_dir=template_dir, ) if return_types: diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index ceb25224..ce298d45 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -64,6 +64,13 @@ type=str, help="File name (without extension) to use for output files, if different from input file name.", ) +@click.option( + "-t", + "--template-dir", + default=None, + type=Path, + help="Directory searched first when resolving a metadata.template.name reference.", +) @click.option( "-V", "--version", @@ -71,7 +78,7 @@ default=False, help=f"Output {APP_NAME} version and exit.", ) -def wireviz(file, format, prepend, output_dir, output_name, version): +def wireviz(file, format, prepend, output_dir, output_name, template_dir, version): """ Parses the provided FILE and generates the specified outputs. @@ -165,6 +172,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version): output_name=_output_name, image_paths=list(image_paths), source_path=file, + template_dir=template_dir, ) sys.stderr.write("\n") diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 4a76d65b..58edaafb 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -24,19 +24,24 @@ def generate_html_output( output_name: Union[str, None] = None, png_b64: Union[str, None] = None, source_path: Union[str, Path, None] = None, + template_dir: Union[str, Path, None] = None, ) -> str: # load HTML template templatename = metadata.get("template", {}).get("name") builtin_template_dir = Path(__file__).parent / "templates" if templatename: - # custom template lookup order: directory of the input YAML - # (source_path), then the output directory, then the built-in - # templates shipped with WireViz. + # custom template lookup order, highest priority first: + # 1. explicit template_dir (CLI -t / parse template_dir) + # 2. YAML source directory (source_path.parent) + # 3. output directory + # 4. built-in templates shipped with WireViz search_paths = [builtin_template_dir] if output_dir is not None: search_paths.insert(0, Path(output_dir)) if source_path is not None: search_paths.insert(0, Path(source_path).parent) + if template_dir is not None: + search_paths.insert(0, Path(template_dir)) templatefile = smart_file_resolve( f"{templatename}.html", search_paths, From c3b2576eaa0dfb0786cd7f9dc6b1ca590543597b Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:01:49 -0400 Subject: [PATCH 02/11] Add output_dpi option (port of upstream PR #379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the Graphviz "dpi" graph attribute as a top-level WireViz YAML option. Useful for boosting the resolution of PNG output beyond the graphviz default of 96 DPI: options: output_dpi: 192 # 2x default — 4x pixel area in the PNG Defaults to 96.0, matching graphviz's own default for non-PostScript renderers, so existing harnesses render at the same pixel dimensions they always have. Files: * DataClasses.py — Options.output_dpi: Optional[float] = 96.0 * Harness.py — pass dpi=str(self.options.output_dpi) into the graph attr * docs/syntax.md — documents the new option under "options" * examples/*.gv, tutorial/*.gv — rebaselined: every .gv now carries ``dpi=96.0``. The .png / .svg / .html outputs are environment- dependent (graphviz version) and intentionally left untouched, per CONTRIBUTING.md's "owner will rebuild" policy. ex08.gv is also intentionally left as baseline because it still contains absolute image paths from the original maintainer's machine. Verified: * Default DPI: demo PNG renders at 428x195 (matches pre-PR baseline). * output_dpi=192: same harness renders at 857x391 — exactly 2x linear scale, 4x pixel area, as expected. * build_examples.py runs cleanly across all examples. Ported from https://github.com/wireviz/WireViz/pull/379 (originally targeting upstream `dev` by Tobias Falk / @tobiasfalk). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/syntax.md | 5 +++++ examples/demo01.gv | 2 +- examples/demo02.gv | 2 +- examples/ex01.gv | 2 +- examples/ex02.gv | 2 +- examples/ex03.gv | 2 +- examples/ex04.gv | 2 +- examples/ex05.gv | 2 +- examples/ex06.gv | 2 +- examples/ex07.gv | 2 +- examples/ex09.gv | 2 +- examples/ex10.gv | 2 +- examples/ex11.gv | 2 +- examples/ex12.gv | 2 +- examples/ex13.gv | 2 +- examples/ex14.gv | 2 +- src/wireviz/DataClasses.py | 4 ++++ src/wireviz/Harness.py | 1 + tutorial/tutorial01.gv | 2 +- tutorial/tutorial02.gv | 2 +- tutorial/tutorial03.gv | 2 +- tutorial/tutorial04.gv | 2 +- tutorial/tutorial05.gv | 2 +- tutorial/tutorial06.gv | 2 +- tutorial/tutorial07.gv | 2 +- tutorial/tutorial08.gv | 2 +- 26 files changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/syntax.md b/docs/syntax.md index 2c92fd34..ae6a8508 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -393,6 +393,11 @@ See [HTML Output Templates](../src/wireviz/templates/) for how metadata entries # Character to split template and designator for autogenerated components template_separator: # Default = '.' + + # Graphviz dpi attribute (https://graphviz.org/docs/attrs/dpi/). + # Controls the resolution of raster (PNG) output and the size unit of + # vector (SVG) output. + output_dpi: # Default = 96.0 ``` diff --git a/examples/demo01.gv b/examples/demo01.gv index e5fb6b64..6b97135c 100644 --- a/examples/demo01.gv +++ b/examples/demo01.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/demo02.gv b/examples/demo02.gv index ca788be8..32e1c70e 100644 --- a/examples/demo02.gv +++ b/examples/demo02.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex01.gv b/examples/ex01.gv index 8dd0e4c4..a88b8c3f 100644 --- a/examples/ex01.gv +++ b/examples/ex01.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex02.gv b/examples/ex02.gv index c0d89388..7c4c863d 100644 --- a/examples/ex02.gv +++ b/examples/ex02.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex03.gv b/examples/ex03.gv index 486b1e2e..a729db9d 100644 --- a/examples/ex03.gv +++ b/examples/ex03.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex04.gv b/examples/ex04.gv index 2db8c5c2..d15b449f 100644 --- a/examples/ex04.gv +++ b/examples/ex04.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] __F_1 [label=< diff --git a/examples/ex05.gv b/examples/ex05.gv index 3dce0bb0..7f7733d9 100644 --- a/examples/ex05.gv +++ b/examples/ex05.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex06.gv b/examples/ex06.gv index ee6b656a..ed67294e 100644 --- a/examples/ex06.gv +++ b/examples/ex06.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex07.gv b/examples/ex07.gv index 1d7c7e6f..794f27c9 100644 --- a/examples/ex07.gv +++ b/examples/ex07.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex09.gv b/examples/ex09.gv index ce6ff89c..2e79f707 100644 --- a/examples/ex09.gv +++ b/examples/ex09.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex10.gv b/examples/ex10.gv index 976494a6..8f0a442a 100644 --- a/examples/ex10.gv +++ b/examples/ex10.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex11.gv b/examples/ex11.gv index 6b859ef2..d666d0b1 100644 --- a/examples/ex11.gv +++ b/examples/ex11.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] __F_1 [label=< diff --git a/examples/ex12.gv b/examples/ex12.gv index f0cb0e91..42e3970e 100644 --- a/examples/ex12.gv +++ b/examples/ex12.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex13.gv b/examples/ex13.gv index 94883108..989c7d67 100644 --- a/examples/ex13.gv +++ b/examples/ex13.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/examples/ex14.gv b/examples/ex14.gv index 776e08b2..f68f925e 100644 --- a/examples/ex14.gv +++ b/examples/ex14.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 14739372..9693268d 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -59,6 +59,10 @@ class Options: color_mode: ColorMode = "SHORT" mini_bom_mode: bool = True template_separator: str = "." + # Graphviz dpi attribute (https://graphviz.org/docs/attrs/dpi/) — controls + # the resolution of raster (PNG) output and the size unit of vector (SVG) + # output. Default 96.0 matches Graphviz's default for non-PostScript output. + output_dpi: Optional[float] = 96.0 def __post_init__(self): if not self.bgcolor_node: diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 1efef9fc..929bedd2 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -175,6 +175,7 @@ def create_graph(self) -> Graph: bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"), nodesep="0.33", fontname=self.options.fontname, + dpi=str(self.options.output_dpi), ) # TODO: Add graph attribute: charset="utf-8", dot.attr( "node", diff --git a/tutorial/tutorial01.gv b/tutorial/tutorial01.gv index 7b53bf80..6a156d2b 100644 --- a/tutorial/tutorial01.gv +++ b/tutorial/tutorial01.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/tutorial/tutorial02.gv b/tutorial/tutorial02.gv index 7098d9af..e04758a9 100644 --- a/tutorial/tutorial02.gv +++ b/tutorial/tutorial02.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/tutorial/tutorial03.gv b/tutorial/tutorial03.gv index 74150559..1dbebd45 100644 --- a/tutorial/tutorial03.gv +++ b/tutorial/tutorial03.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/tutorial/tutorial04.gv b/tutorial/tutorial04.gv index 1e5a7421..550cdabd 100644 --- a/tutorial/tutorial04.gv +++ b/tutorial/tutorial04.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/tutorial/tutorial05.gv b/tutorial/tutorial05.gv index 4140a139..18062fc1 100644 --- a/tutorial/tutorial05.gv +++ b/tutorial/tutorial05.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] __F1_1 [label=< diff --git a/tutorial/tutorial06.gv b/tutorial/tutorial06.gv index 2976b7d5..b5f43c57 100644 --- a/tutorial/tutorial06.gv +++ b/tutorial/tutorial06.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] __F_05_1 [label=< diff --git a/tutorial/tutorial07.gv b/tutorial/tutorial07.gv index d2c45b91..4cfedf38 100644 --- a/tutorial/tutorial07.gv +++ b/tutorial/tutorial07.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< diff --git a/tutorial/tutorial08.gv b/tutorial/tutorial08.gv index 761ccb85..b91ac9e1 100644 --- a/tutorial/tutorial08.gv +++ b/tutorial/tutorial08.gv @@ -1,7 +1,7 @@ graph { // Graph generated by WireViz 0.4.1 // https://github.com/wireviz/WireViz - graph [bgcolor="#FFFFFF" fontname=arial nodesep=0.33 rankdir=LR ranksep=2] + graph [bgcolor="#FFFFFF" dpi=96.0 fontname=arial nodesep=0.33 rankdir=LR ranksep=2] node [fillcolor="#FFFFFF" fontname=arial height=0 margin=0 shape=none style=filled width=0] edge [fontname=arial style=bold] X1 [label=< From c0f8eee151ae6da060d901c85af1a425f0f7fae1 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:07:09 -0400 Subject: [PATCH 03/11] Document new parameters in Harness.output(), _render(), and parse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist feedback on https://github.com/ClassicMiniDIY/WireViz/pull/3 — template_dir was missing from the docstrings of Harness.output(), Harness._render(), and wireviz.parse(). Expanded scope to also document the parameters that earlier PRs in this chain added without docstring coverage: * Harness.output(): Args section now describes filename (incl. None → stdout semantics), fmt (incl. str→tuple normalization), output_dir, output_name, and template_dir; view/cleanup are noted as kept for API compat. * Harness._render(): Args + Returns sections describing fmt, output_dir, output_name, template_dir, and the bytes-vs-str per-format return contract. * wireviz.parse(): source_path (added during PR #1's loopback fix and threaded through PR #2's stdin/stdout port) and template_dir (this PR) added to the Args section, with template-search-priority semantics spelled out. No behavior change. Verified against build_examples.py: deterministic outputs unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 40 ++++++++++++++++++++++++++++++++++++++++ src/wireviz/wireviz.py | 11 +++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 294297af..c77f8713 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -687,6 +687,26 @@ def output( ``filename`` is None, exactly one format must be requested and its bytes/text are written to stdout — supports piping the CLI into other tools. + + Args: + filename: Output base path (without extension). ``None`` + routes a single format to stdout instead of writing files. + fmt: One or more formats from ``html``, ``png``, ``svg``, + ``gv``, ``tsv``, ``csv``, ``pdf``. A bare string is + normalized to a one-tuple. + view: Reserved (unused — kept for API compatibility with the + pre-refactor signature). + cleanup: Reserved (unused — kept for API compatibility). + output_dir: Output directory. Used only to populate the + ```` HTML template placeholder and to + resolve a custom ``metadata.template.name`` reference. + output_name: Output base name (without extension). Used only + to populate the ```` HTML + template placeholder. + template_dir: Explicit directory to search first when + resolving a ``metadata.template.name`` reference. Falls + through to the YAML source directory, then ``output_dir``, + then the built-in templates shipped with WireViz. """ if isinstance(fmt, str): fmt = (fmt,) @@ -737,6 +757,26 @@ def _render( Pipes graphviz once per binary output rather than via ``render()`` + temporary files so the caller can write files OR pipe to stdout without the SVG-file roundtrip the previous implementation used. + + Args: + fmt: One or more formats from ``html``, ``png``, ``svg``, + ``gv``, ``tsv``. ``csv`` and ``pdf`` are recognized at + the dispatch layer but not produced here. A bare string + is normalized to a one-tuple. + output_dir: Forwarded to ``generate_html_output`` for + ```` and ```` + template-placeholder resolution, and as the third-priority + directory in the custom-template search path. + output_name: Forwarded to ``generate_html_output`` for + ```` resolution. + template_dir: Forwarded to ``generate_html_output`` as the + first-priority directory in the custom-template search + path. + + Returns: + ``{format: bytes|str}``. Binary formats (``png``) yield + bytes; text formats (``svg``, ``html``, ``gv``, ``tsv``) + yield str. """ if isinstance(fmt, str): fmt = (fmt,) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 880eff63..de7b7a11 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -77,6 +77,17 @@ def parse( Paths to use when resolving any image paths included in the data. Note: If inp is a path to a YAML file, its parent directory will automatically be included in the list. + source_path (Path | str, optional): + Path of the originating YAML file when ``inp`` is a string or dict. + Used to: (1) resolve a custom ``metadata.template.name`` reference + against the source's directory, and (2) resolve relative + ```` paths embedded in graphviz output. + When ``inp`` is itself a Path, this is filled in automatically. + template_dir (Path | str, optional): + Explicit first-priority directory to search when resolving a + ``metadata.template.name`` reference. Searched before the YAML + source directory and the output directory; the built-in + templates ship as the final fallback. Returns: Depending on the return_types parameter, may return: From 0d87b4438109d13f48db622deff468b20997b6e7 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:10:48 -0400 Subject: [PATCH 04/11] Skip dpi graph attr when output_dpi is None (PR #4 review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist feedback on https://github.com/ClassicMiniDIY/WireViz/pull/4 — the prior ``dpi=str(self.options.output_dpi)`` would emit the literal string ``"None"`` if a user set ``output_dpi: null`` in YAML, which Graphviz treats as an invalid value. The reviewer's exact suggestion (``dpi=self.options.output_dpi`` — relying on graphviz auto-coercion of numerics) doesn't quite work either: the graphviz Python lib filters None but does NOT auto-convert ints/floats to strings (``dpi=192`` raises ``TypeError: expected string or bytes-like object, got 'int'``). So compromise: build the graph attr dict, conditionally include the dpi key only when output_dpi is not None, and stringify it ourselves. Verified: * ``output_dpi: 96.0`` (default) — emits ``dpi=96.0`` as before; all example .gv baselines remain byte-identical. * ``output_dpi: 192`` — emits ``dpi=192``; PNG renders at 2x scale (857x391 vs 428x195 default). * ``output_dpi: null`` — no dpi attr emitted; PNG renders at Graphviz's renderer default (96 for PNG → matches default-scale). Also updates the DataClasses.Options.output_dpi comment to document the null-as-defer-to-graphviz semantic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/DataClasses.py | 4 +++- src/wireviz/Harness.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 9693268d..66b92c38 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -61,7 +61,9 @@ class Options: template_separator: str = "." # Graphviz dpi attribute (https://graphviz.org/docs/attrs/dpi/) — controls # the resolution of raster (PNG) output and the size unit of vector (SVG) - # output. Default 96.0 matches Graphviz's default for non-PostScript output. + # output. Default 96.0 matches Graphviz's default for non-PostScript + # output. Set to ``null`` in YAML (``None`` in Python) to omit the dpi + # attribute entirely and let Graphviz pick its renderer-specific default. output_dpi: Optional[float] = 96.0 def __post_init__(self): diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 929bedd2..83d9ec75 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -168,15 +168,20 @@ def create_graph(self) -> Graph: dot = Graph() dot.body.append(f"// Graph generated by {APP_NAME} {__version__}\n") dot.body.append(f"// {APP_URL}\n") - dot.attr( - "graph", + graph_attrs = dict( rankdir="LR", ranksep="2", bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"), nodesep="0.33", fontname=self.options.fontname, - dpi=str(self.options.output_dpi), - ) # TODO: Add graph attribute: charset="utf-8", + ) + # Pass dpi only when set; output_dpi: null in YAML means "let + # Graphviz pick its default" (96 for non-PostScript renderers). + # Stringified because the graphviz Python lib doesn't coerce + # numerics for us. + if self.options.output_dpi is not None: + graph_attrs["dpi"] = str(self.options.output_dpi) + dot.attr("graph", **graph_attrs) # TODO: Add graph attribute: charset="utf-8", dot.attr( "node", shape="none", From dee9e67264f5e05d99b4389715021c3e2604325f Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:25:10 -0400 Subject: [PATCH 05/11] Embed YAML source in PNG output for round-trip editing (port of upstream PR #234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders now embed the source YAML in PNG output as a zlib-compressed iTXt chunk under the key ``wireviz:yaml``. The CLI auto-detects ``.png`` inputs and pulls the YAML back out, so a single PNG file is enough to re-render or edit a harness — no sidecar .yml needed. The headline workflow: wireviz harness.yml # produces harness.png with yaml inside wireviz harness.png # round-trips: extract YAML, re-render This is the load-bearing capability for the upcoming wireviz-gui: drag a PNG into the editor and recover the source. Without it, every PNG in the wild is an opaque artifact divorced from its model. API surface: * wireviz.parse() gains ``embed_yaml: bool = True``. The default embeds; pass False to render plain PNGs without source-bearing metadata. * Harness.output() / _render() gain ``yaml_source: Optional[str]``. When non-None and PNG is in the requested formats, the rendered PNG bytes are post-processed through PIL to attach the iTXt chunk. * New module-level helpers in Harness.py: - PNG_YAML_CHUNK_KEY = "wireviz:yaml" - _embed_yaml_in_png(png_bytes, yaml_source) -> bytes - read_yaml_from_png(png_path) -> Optional[str] * CLI ``--no-embed-yaml`` flag opts out of embedding when desired (e.g. before sharing a diagram externally without source). Implementation notes: * The chunk uses ``iTXt`` (international text, zip-compressed) rather than ``zTXt`` so unicode YAML round-trips cleanly. Key prefix ``wireviz:`` namespaces the chunk against PNG software-defined keywords. * When parse() is called with a Dict input, we yaml.safe_dump it back for embedding — round-trip-readable, but without the original comments or formatting (those don't survive the dict-conversion step regardless of embedding). * build_examples.py opts out (``embed_yaml=False``) so the regression baseline PNGs stay deterministic. Adapted from https://github.com/wireviz/WireViz/pull/234 (originally by @jacobian91, targeting upstream ``dev``). The 2021-era PR was heavily bit-rotted — argparse, the old parse_cmdline / parse_file layer, conceal-input enum — only the load-bearing idea (zTXT/iTXt embed in PNG, .png input recovery) was preserved. Reworked against current master's click CLI, the in-memory render dict from PR #321 stdin/stdout, and threaded source_path / template_dir from earlier PRs in this chain. Verified: * round-trip: harness.yml → harness.png → re-extract → identical YAML * --no-embed-yaml produces a PNG without the chunk (verified via PIL) * ``wireviz harness.png`` on a chunk-less PNG raises a clean click.UsageError * build_examples.py runs cleanly; .gv and .bom.tsv byte-identical to baseline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 62 +++++++++++++++++++++++++++++++++++ src/wireviz/build_examples.py | 6 +++- src/wireviz/wireviz.py | 21 +++++++++--- src/wireviz/wv_cli.py | 36 ++++++++++++++++++-- 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index c753cae1..ea0eac9e 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import base64 +import io import re import sys from collections import Counter @@ -10,6 +11,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union from graphviz import Graph +from PIL import Image as PILImage +from PIL.PngImagePlugin import PngInfo from wireviz import APP_NAME, APP_URL, __version__, wv_colors from wireviz.DataClasses import ( Cable, @@ -59,6 +62,51 @@ "autogenerate": "is replaced with new syntax in v0.4", } +# iTXt chunk key used to embed the source YAML in rendered PNGs for +# round-trip editing. The "wireviz:" prefix avoids collision with PNG +# software-defined keywords or other tools' chunks. +PNG_YAML_CHUNK_KEY = "wireviz:yaml" + + +def _embed_yaml_in_png(png_bytes: bytes, yaml_source: str) -> bytes: + """Re-encode PNG bytes with the YAML source stored in an iTXt chunk. + + Pillow's PNG write path does not natively support adding a single + chunk to an existing file, so this decodes and re-encodes. To keep + the round-trip non-destructive, anything Pillow surfaced via + ``im.info`` (DPI, color profiles, existing text chunks) is carried + forward, and existing iTXt entries on the source image are merged + in alongside the new ``wireviz:yaml`` chunk. + """ + with PILImage.open(io.BytesIO(png_bytes)) as im: + im.load() + chunks = PngInfo() + # Preserve any existing iTXt chunks (e.g. dpi metadata or + # downstream-tool annotations) — Pillow surfaces them in im.text. + existing_text = getattr(im, "text", {}) or {} + for key, value in existing_text.items(): + if key == PNG_YAML_CHUNK_KEY: + continue # we're about to write a fresh one + chunks.add_itxt(key, value, zip=True) + chunks.add_itxt(PNG_YAML_CHUNK_KEY, yaml_source, zip=True) + out = io.BytesIO() + # ``**im.info`` carries forward DPI, color profile, gamma, etc. + # Filter the keys Pillow's PNG writer accepts to avoid TypeErrors + # from unrelated info entries. + png_save_keys = {"dpi", "gamma", "transparency", "icc_profile"} + save_kwargs = {k: v for k, v in im.info.items() if k in png_save_keys} + im.save(out, format="PNG", pnginfo=chunks, **save_kwargs) + return out.getvalue() + + +def read_yaml_from_png(png_path: Union[str, Path]) -> Optional[str]: + """Return the YAML source embedded in ``png_path`` by an earlier + WireViz render, or ``None`` if no ``wireviz:yaml`` chunk is present. + """ + with PILImage.open(png_path) as im: + im.load() + return im.text.get(PNG_YAML_CHUNK_KEY) if hasattr(im, "text") else None + def check_old(node: str, old_attr: dict, args: dict) -> None: """Raise exception for any outdated attributes in args.""" @@ -685,6 +733,7 @@ def output( output_dir: Optional[Union[str, Path]] = None, output_name: Optional[str] = None, template_dir: Optional[Union[str, Path]] = None, + yaml_source: Optional[str] = None, ) -> None: """Render the harness in the requested formats. @@ -694,6 +743,12 @@ def output( its bytes/text are written to stdout — supports piping the CLI into other tools. + If ``yaml_source`` is provided and PNG output is requested, the + YAML source string is embedded in the PNG as an iTXt chunk under + the key ``wireviz:yaml`` for round-trip editing. Recovery via + ``Harness.read_yaml_from_png()`` or ``wireviz.parse()`` with a + .png input file. + Args: filename: Output base path (without extension). ``None`` routes a single format to stdout instead of writing files. @@ -713,6 +768,9 @@ def output( resolving a ``metadata.template.name`` reference. Falls through to the YAML source directory, then ``output_dir``, then the built-in templates shipped with WireViz. + yaml_source: Source YAML string. When non-None and PNG is in + ``fmt``, embedded as an iTXt chunk in the PNG output for + round-trip editing. """ if isinstance(fmt, str): fmt = (fmt,) @@ -721,6 +779,7 @@ def output( output_dir=output_dir, output_name=output_name, template_dir=template_dir, + yaml_source=yaml_source, ) if "csv" in fmt: @@ -757,6 +816,7 @@ def _render( output_dir: Optional[Union[str, Path]] = None, output_name: Optional[str] = None, template_dir: Optional[Union[str, Path]] = None, + yaml_source: Optional[str] = None, ) -> Dict[str, Union[str, bytes]]: """Produce in-memory representations of each requested format. @@ -810,6 +870,8 @@ def _render( png_bytes: Optional[bytes] = None if "png" in fmt: png_bytes = graph.pipe(format="png") + if yaml_source is not None: + png_bytes = _embed_yaml_in_png(png_bytes, yaml_source) outputs["png"] = png_bytes if "gv" in fmt: diff --git a/src/wireviz/build_examples.py b/src/wireviz/build_examples.py index e54d0f5c..80d422f9 100755 --- a/src/wireviz/build_examples.py +++ b/src/wireviz/build_examples.py @@ -64,7 +64,11 @@ def build_generated(groupkeys): # collect and iterate input YAML files for yaml_file in collect_filenames("Building", key, input_extensions): print(f' "{yaml_file}"') - wireviz.parse(yaml_file, output_formats=("gv", "html", "png", "svg", "tsv")) + wireviz.parse( + yaml_file, + output_formats=("gv", "html", "png", "svg", "tsv"), + embed_yaml=False, # keep example PNG bytes deterministic + ) if build_readme: i = "".join(filter(str.isdigit, yaml_file.stem)) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index de7b7a11..0bd770cf 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -33,6 +33,7 @@ def parse( image_paths: Union[Path, str, List] = [], source_path: Union[Path, str, None] = None, template_dir: Union[Path, str, None] = None, + embed_yaml: bool = True, ) -> Any: """ This function takes an input, parses it as a WireViz Harness file, @@ -88,6 +89,11 @@ def parse( ``metadata.template.name`` reference. Searched before the YAML source directory and the output directory; the built-in templates ship as the final fallback. + embed_yaml (bool, optional): + When True (default) and PNG output is requested, the YAML + source is embedded in the PNG as an iTXt chunk under the + ``wireviz:yaml`` key for round-trip editing. Set to False + to render plain PNGs without source-bearing metadata. Returns: Depending on the return_types parameter, may return: @@ -101,7 +107,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)}" @@ -427,6 +433,7 @@ def alternate_type(): # flip between connector and cable/arrow for line in yaml_data["additional_bom_items"]: harness.add_bom_item(line) + yaml_source_for_png = yaml_str if embed_yaml else None if output_formats: if write_to_stdout: if len(output_formats) != 1: @@ -438,6 +445,7 @@ def alternate_type(): # flip between connector and cable/arrow fmt=output_formats, view=False, template_dir=template_dir, + yaml_source=yaml_source_for_png, ) else: harness.output( @@ -447,6 +455,7 @@ def alternate_type(): # flip between connector and cable/arrow output_dir=output_dir, output_name=output_name, template_dir=template_dir, + yaml_source=yaml_source_for_png, ) if return_types: @@ -467,7 +476,9 @@ def alternate_type(): # flip between connector and cable/arrow return tuple(returns) if len(returns) != 1 else returns[0] -def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path): +def _get_yaml_data_and_path( + inp: Union[str, Path, Dict], +) -> Tuple[Dict, Optional[Path], Optional[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: @@ -494,10 +505,12 @@ def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path): yaml_path = None yaml_data = yaml.safe_load(yaml_str) else: - # received a Dict, use as-is + # received a Dict — serialize back to YAML so the caller has a + # text form for round-trip embedding into PNG output. yaml_data = inp yaml_path = None - return yaml_data, yaml_path + yaml_str = yaml.safe_dump(inp, sort_keys=False, allow_unicode=True) + return yaml_data, yaml_path, yaml_str def _get_output_dir(input_file: Path, default_output_dir: Path) -> Path: diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index ce298d45..d95154c2 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -11,6 +11,7 @@ import wireviz.wireviz as wv from wireviz import APP_NAME, __version__ +from wireviz.Harness import read_yaml_from_png from wireviz.wv_helper import file_read_text format_codes = { @@ -71,6 +72,13 @@ type=Path, help="Directory searched first when resolving a metadata.template.name reference.", ) +@click.option( + "--no-embed-yaml", + "embed_yaml", + flag_value=False, + default=True, + help="Do not embed the source YAML in PNG output as an iTXt chunk.", +) @click.option( "-V", "--version", @@ -78,7 +86,9 @@ default=False, help=f"Output {APP_NAME} version and exit.", ) -def wireviz(file, format, prepend, output_dir, output_name, template_dir, version): +def wireviz( + file, format, prepend, output_dir, output_name, template_dir, embed_yaml, version +): """ Parses the provided FILE and generates the specified outputs. @@ -149,9 +159,28 @@ def wireviz(file, format, prepend, output_dir, output_name, template_dir, versio if not file.exists(): raise Exception(f"File does not exist:\n{file}") - yaml_input = prepend_input + file_read_text(file) + if file.suffix.lower() == ".png": + # PNG input: try to recover the YAML embedded by an + # earlier WireViz render. Catch PIL's UnidentifiedImageError + # (and anything else PIL throws for corrupt files) so the + # user sees a clean message instead of a stack trace. + try: + embedded = read_yaml_from_png(file) + except Exception as exc: + raise click.UsageError( + f"Could not read PNG {file}: {exc}" + ) from exc + if embedded is None: + raise click.UsageError( + f"{file} has no embedded WireViz YAML (no " + f"'wireviz:yaml' iTXt chunk found)." + ) + yaml_input = prepend_input + embedded + sys.stderr.write(f"Input file: {file} (extracted YAML)\n") + else: + yaml_input = prepend_input + file_read_text(file) + sys.stderr.write(f"Input file: {file}\n") image_paths = {file.parent} - sys.stderr.write(f"Input file: {file}\n") _output_dir = output_dir if output_dir else file.parent _output_name = output_name if output_name else file.stem @@ -173,6 +202,7 @@ def wireviz(file, format, prepend, output_dir, output_name, template_dir, versio image_paths=list(image_paths), source_path=file, template_dir=template_dir, + embed_yaml=embed_yaml, ) sys.stderr.write("\n") From 33f63b55c73860110e582c313a723b9756952dd4 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:27:50 -0400 Subject: [PATCH 06/11] Add HTML template placeholder (port of upstream PR #492) Resolves to the key of the most recently added entry in ``metadata.revisions``. Useful in branded HTML chrome to surface a "current revision" badge without expanding to the full ```` indexed form. Example template fragment: Rev Adapted from https://github.com/wireviz/WireViz/pull/492 (originally by @ishaid, targeting upstream ``dev``). The upstream patch was against ``wv_output.py`` (a ``dev``-only renaming of ``wv_html.py``); this port lives in master's ``wv_html.py``. Helper renamed from ``_get_latest_revision`` to ``_latest_revision`` and tightened to return ``""`` for missing/None/empty revisions instead of raising. Documents the new placeholder in templates/README.md. Verified: * Direct unit test: ``_latest_revision({"revisions": {"A": ..., "B": ..., "C": ...}})`` returns ``"C"``. * Empty/missing/None ``revisions`` returns ``""``. * build_examples.py: deterministic outputs (.gv, .bom.tsv) byte- identical to baseline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/templates/README.md | 1 + src/wireviz/wv_html.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/wireviz/templates/README.md b/src/wireviz/templates/README.md index 31d3ef88..e9b4692b 100644 --- a/src/wireviz/templates/README.md +++ b/src/wireviz/templates/README.md @@ -40,6 +40,7 @@ Note that there must be one single space between `--` and `%` at both ends. | `` | `1` (multi-page documents not yet supported) | | `` | Embedded SVG diagram as valid HTML | | `` | Embedded base64 encoded PNG diagram as URI | +| `` | Name (key) of the last entry in `metadata.revisions`, or empty string | | `` | String or numeric value of `metadata.{item}` | | `` | Category number `{i}` within dict value of `metadata.{item}` | | `` | Value of `metadata.{item}.{category}.{key}` | diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 58edaafb..002fdb13 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -15,6 +15,17 @@ ) +def _latest_revision(metadata: Metadata) -> str: + """Return the key of the most recently added entry in + ``metadata.revisions``. Relies on Python dict insertion-order + preservation; YAML parsers preserve the document order. Returns + "" if no revisions are declared.""" + revisions = metadata.get("revisions") if metadata else None + if not revisions: + return "" + return str(list(revisions)[-1]) + + def generate_html_output( svg_input: Union[str, None], bom_list: List[List[str]], @@ -112,6 +123,7 @@ def svgdata() -> str: "": metadata.get("template", {}).get( "sheetsize", "" ), + "": _latest_revision(metadata), } def replacement_if_used(key: str, func: Callable[[], str]) -> None: From 9b055abc5d666e61f513907a8c7c83e934e998c0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:52:33 -0400 Subject: [PATCH 07/11] Handle scalar/None ``revisions`` value in _latest_revision (PR #6 review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist feedback on https://github.com/ClassicMiniDIY/WireViz/pull/6 — the prior ``str(list(revisions)[-1])`` form returned only the last *character* when ``revisions:`` was a string scalar (e.g. ``revisions: v1.0`` → ``"0"``), and would raise ``TypeError`` on a non-iterable scalar like an integer. Now branches on type: * dict / list → last key/element (preserves prior behavior) * str / int / float / any other non-None scalar → str(value) * None / empty container / missing → "" Verified against all six shapes: {'revisions': {'A': ..., 'B': ..., 'C': ...}} -> 'C' {'revisions': ['A', 'B', 'C']} -> 'C' {'revisions': 'v1.2'} -> 'v1.2' {'revisions': 42} -> '42' {'revisions': None} -> '' {'revisions': {}} -> '' Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/wv_html.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 002fdb13..2393317d 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -17,13 +17,17 @@ def _latest_revision(metadata: Metadata) -> str: """Return the key of the most recently added entry in - ``metadata.revisions``. Relies on Python dict insertion-order - preservation; YAML parsers preserve the document order. Returns - "" if no revisions are declared.""" + ``metadata.revisions`` when revisions is a dict or list, or the + value itself when it is a scalar (string/int/float). + + Dict/list relies on Python's insertion-order preservation; YAML + parsers preserve document order. Returns "" for missing, empty, + or None values. + """ revisions = metadata.get("revisions") if metadata else None - if not revisions: - return "" - return str(list(revisions)[-1]) + if isinstance(revisions, (dict, list)): + return str(list(revisions)[-1]) if revisions else "" + return str(revisions) if revisions is not None else "" def generate_html_output( From e75ff5c636c5483a338effa2af1f3ee9bdebe6b3 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:30:27 -0400 Subject: [PATCH 08/11] Add per-connector / per-cable tweak with name placeholder (port of upstream PR #357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connectors and cables can now carry their own ``tweak:`` block with the same ``override`` / ``append`` shape as the global one. The harness folds per-node tweaks into the global tweak at instantiation, with an optional placeholder substring rewritten to the node's actual designator — making it practical to author a single tweak template and apply it to many components. Example: tweak: placeholder: "@@" connectors: X1: pinlabels: [A, B] tweak: append: - "@@_extra [color=red, style=dashed];" renders X1's per-node tweak as ``X1_extra [color=red, style=dashed];`` in the .gv source. The same connector definition reused for X2 would produce ``X2_extra ...``. Placeholder semantics: * Per-node ``tweak.placeholder`` overrides the global ``tweak.placeholder``. * An empty string at the per-node level explicitly opts out of substitution for that node. * ``None`` (the default) falls back to the global placeholder. * When neither is set, no substitution happens — bare strings are appended/overridden as-written. Implementation: * DataClasses.py — ``Tweak`` gains ``placeholder: Optional[str] = None``. ``Connector`` and ``Cable`` gain ``tweak: Optional[Tweak] = None``, with ``__post_init__`` coercing a dict literal into a Tweak instance. * Harness.py — new ``Harness._extend_tweak(node)`` method, called from ``add_connector()`` and ``add_cable()``, performs the placeholder substitution and merges into ``self.tweak``. Raises ``ValueError`` if two nodes contribute conflicting overrides for the same key. * docs/syntax.md — documents per-connector / per-cable tweak fields and the new placeholder semantics. Adapted from https://github.com/wireviz/WireViz/pull/357 (originally by @kvid, targeting upstream ``dev``). Renamed ``extend_tweak`` to ``_extend_tweak`` to mark it private; otherwise faithful to the original logic. ``make_list`` is already in master's wv_bom so no helper backport needed. Verified: * Smoke test with placeholder ``@@`` and per-connector + per-cable ``append:`` blocks produces ``X1_extra``, ``W1_label``, ``cable W1`` in the rendered .gv (the @@'s are substituted). * build_examples.py: deterministic outputs (.gv, .bom.tsv) byte- identical to baseline (no existing example uses the new syntax, so no new substitutions fire). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/syntax.md | 11 ++++++++++ src/wireviz/DataClasses.py | 7 +++++++ src/wireviz/Harness.py | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/syntax.md b/docs/syntax.md index ae6a8508..0811699a 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -85,6 +85,9 @@ tweak: # optional tweaking of .gv output # loops loops: # every list item is itself a list of exactly two pins # on the connector that are to be shorted + + # optional tweaking of .gv output executed for each instance of this connector + tweak: # see tweak section below ``` ## Cable attributes @@ -148,6 +151,8 @@ tweak: # optional tweaking of .gv output show_wirecount: # defaults to true show_wirenumbers: # defaults to true for cables; false for bundles + # optional tweaking of .gv output executed for each instance of this cable + tweak: # see tweak section below ``` ## Connection sets @@ -457,6 +462,12 @@ Alternatively items can be added to just the BOM by putting them in the section # This feature is experimental and might change # or be removed in future versions. + placeholder: # Substring to be replaced with the node name in + # any per-connector / per-cable tweak overrides and append entries. + # An empty string disables placeholder substitution for that node. + # When omitted at the per-node level, the global placeholder + # (in the top-level tweak: section) is used as the fallback. + override: # dict of .gv entries to override # Each entry is identified by its leading string # in lines beginning with a TAB character. diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 66b92c38..68ec326d 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -79,6 +79,7 @@ def __post_init__(self): @dataclass class Tweak: + placeholder: Optional[PlainText] = None override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None append: Union[str, List[str], None] = None @@ -170,10 +171,13 @@ class Connector: loops: List[List[Pin]] = field(default_factory=list) ignore_in_bom: bool = False additional_components: List[AdditionalComponent] = field(default_factory=list) + tweak: Optional[Tweak] = None def __post_init__(self) -> None: if isinstance(self.image, dict): self.image = Image(**self.image) + if isinstance(self.tweak, dict): + self.tweak = Tweak(**self.tweak) self.ports_left = False self.ports_right = False @@ -335,10 +339,13 @@ class Cable: show_wirenumbers: Optional[bool] = None ignore_in_bom: bool = False additional_components: List[AdditionalComponent] = field(default_factory=list) + tweak: Optional[Tweak] = None def __post_init__(self) -> None: if isinstance(self.image, dict): self.image = Image(**self.image) + if isinstance(self.tweak, dict): + self.tweak = Tweak(**self.tweak) if isinstance(self.gauge, str): # gauge and unit specified try: diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index c753cae1..9c0a42ea 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -30,6 +30,7 @@ component_table_entry, generate_bom, get_additional_component_table, + make_list, pn_info_string, ) from wireviz.wv_colors import get_color_hex, translate_color @@ -84,9 +85,49 @@ def __post_init__(self): def add_connector(self, name: str, *args, **kwargs) -> None: check_old(f"Connector '{name}'", OLD_CONNECTOR_ATTR, kwargs) self.connectors[name] = Connector(name, *args, **kwargs) + self._extend_tweak(self.connectors[name]) def add_cable(self, name: str, *args, **kwargs) -> None: self.cables[name] = Cable(name, *args, **kwargs) + self._extend_tweak(self.cables[name]) + + def _extend_tweak(self, node: Union[Connector, Cable]) -> None: + """Fold ``node.tweak`` into ``self.tweak`` after substituting the + node's name for the placeholder string. + + Per-connector / per-cable ``tweak:`` entries let users author a + single template and have its ``override`` keys / ``append`` lines + rewritten with the actual designator at instantiation time. This + is the only place the placeholder substitution happens — the + global tweak is applied unchanged at graph emission time. + """ + if not node.tweak: + return + ph = node.tweak.placeholder + # An empty string is a legal value to opt out of the global + # placeholder; only None falls back. + if ph is None: + ph = self.tweak.placeholder + rph = (lambda s: s.replace(ph, node.name)) if ph else (lambda s: s) + + n_override = node.tweak.override or {} + s_override = self.tweak.override or {} + for ident, n_dict in n_override.items(): + ident = rph(ident) + s_dict = s_override.get(ident, {}) + for k, v in n_dict.items(): + k, v = rph(k), rph(v) + if k in s_dict and v != s_dict[k]: + raise ValueError( + f"{node.name}.tweak.override.{ident}.{k} conflicts with another" + ) + s_dict[k] = v + s_override[ident] = s_dict or None + self.tweak.override = s_override or None + self.tweak.append = ( + make_list(self.tweak.append) + + [rph(v) for v in make_list(node.tweak.append)] + ) or None def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_type) -> None: self.mates.append(MatePin(from_name, from_pin, to_name, to_pin, arrow_type)) From 39129a4c6a7ff31026715a7017a91fa628266ce8 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:55:56 -0400 Subject: [PATCH 09/11] Harden _extend_tweak against None override values (PR #7 review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist feedback on https://github.com/ClassicMiniDIY/WireViz/pull/7: * The ``rph`` lambda would raise ``AttributeError`` when called with a None value, which is a legitimate case in YAML when an override deletes a key (``key: null``). Now passes non-strings through unchanged so substitution is a no-op for None / numeric / bool values. * ``s_override[ident] = s_dict or None`` would collapse an empty per-ident override dict to None, which Harness.create_graph() doesn't expect (it iterates ``override.items()`` expecting dict-shaped values). Always store the dict, even when empty — ``self.tweak.override = s_override or None`` already handles the outer "no overrides at all" case. Verified: per-connector override with ``key: null`` now renders without the prior AttributeError. build_examples.py deterministic outputs still byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 9c0a42ea..66461362 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -108,7 +108,12 @@ def _extend_tweak(self, node: Union[Connector, Cable]) -> None: # placeholder; only None falls back. if ph is None: ph = self.tweak.placeholder - rph = (lambda s: s.replace(ph, node.name)) if ph else (lambda s: s) + # The replacement target may be None when an override deletes a + # key (``key: null`` in YAML), so guard the str.replace call. + if ph: + rph = lambda s: s.replace(ph, node.name) if isinstance(s, str) else s + else: + rph = lambda s: s n_override = node.tweak.override or {} s_override = self.tweak.override or {} @@ -122,7 +127,10 @@ def _extend_tweak(self, node: Union[Connector, Cable]) -> None: f"{node.name}.tweak.override.{ident}.{k} conflicts with another" ) s_dict[k] = v - s_override[ident] = s_dict or None + # Keep the empty dict rather than collapsing to None — the + # graph-emission code (Harness.create_graph) expects values + # in self.tweak.override to be dicts, not None. + s_override[ident] = s_dict self.tweak.override = s_override or None self.tweak.append = ( make_list(self.tweak.append) From 44e19519fc09edfc644300d6c24acbd951528244 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:31:59 -0400 Subject: [PATCH 10/11] Wire up PDF output (port of upstream PR #367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ``pdf`` output format that's been a TODO stub since v0.4.1. Pipes the graph through Graphviz's PDF renderer (``graph.pipe(format="pdf")``) and dispatches the bytes the same way PNG goes — to a file in normal mode, to ``sys.stdout.buffer`` in stdout mode. Usage: wireviz -f P harness.yml # produces harness.pdf cat harness.yml | wireviz -f P -O - - # stdout, binary CLI flag changes: * ``"P": "pdf"`` un-commented in ``format_codes`` (use ``-f P``) * "PDF output is not yet supported" stderr warning removed from Harness.output() Adapted from https://github.com/wireviz/WireViz/pull/367 (originally by @tobiasfalk, targeting upstream ``dev``). The upstream patch went through the old ``graph.render()`` + temp-file path — this port uses the in-memory ``graph.pipe()`` wired up by the stdin/stdout refactor (PR #321), so PDF works in both file mode AND stdout mode without extra plumbing. Verified: * ``wireviz -f P harness.yml`` produces a valid PDF (file 1.7). * ``cat harness.yml | wireviz -f P -O - -`` writes valid PDF to stdout. * build_examples.py: deterministic outputs (.gv, .bom.tsv) byte- identical (no example .yml has been switched to request PDF rendering — keeping that out of the regression baseline since PDF bytes from graphviz vary by version). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 6 +++--- src/wireviz/wv_cli.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index c753cae1..4784b6be 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -726,9 +726,6 @@ def output( if "csv" in fmt: # TODO: implement CSV output (preferably using CSV library) sys.stderr.write("CSV output is not yet supported\n") - if "pdf" in fmt: - # TODO: implement PDF output - sys.stderr.write("PDF output is not yet supported\n") if filename is None: # stdout mode — emit each rendered format in the user-requested order @@ -812,6 +809,9 @@ def _render( png_bytes = graph.pipe(format="png") outputs["png"] = png_bytes + if "pdf" in fmt: + outputs["pdf"] = graph.pipe(format="pdf") + if "gv" in fmt: outputs["gv"] = graph.source diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index ce298d45..acb24be0 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -18,7 +18,7 @@ "g": "gv", "h": "html", "p": "png", - # "P": "pdf", + "P": "pdf", "s": "svg", "t": "tsv", } From 10baef3fe81e7b2ec244dbe2fc81762906389d41 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 20:59:14 -0400 Subject: [PATCH 11/11] Align parse() PDF docstring with diagram-only reality (PR #8 review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist feedback on https://github.com/ClassicMiniDIY/WireViz/pull/8 — the prior docstring claimed PDF includes "the diagram and (depending on the template) the BOM", which was carried over from upstream's never-completed PDF stub plan. The actual implementation in this PR pipes the graph through Graphviz's PDF renderer (graph.pipe(format="pdf")), which produces a diagram-only PDF with no embedded BOM table. That matches the PNG/SVG behavior and is the right scope for a fork that already exposes HTML+SVG embed for richer output. Embedding the BOM in PDF would require a full document-composition step (PIL or reportlab) that's well outside the scope of "implement the missing format flag" — and HTML output exists for users who want diagram + BOM in one artifact. No code change; docstring only. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/wireviz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index de7b7a11..6a164f68 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -53,7 +53,7 @@ def parse( * "gv": the diagram, as a GraphViz source file * "html": the diagram and (depending on the template) the BOM, as a HTML file * "png": the diagram, as a PNG raster image - * "pdf": the diagram and (depending on the template) the BOM, as a PDF file + * "pdf": the diagram, as a PDF document (no BOM — see "html" for that) * "svg": the diagram, as a SVG vector image * "tsv": the BOM, as a tab-separated text file