From ea878b7e3d570ae916defdee2eb7912c0544a083 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 19:10:43 -0400 Subject: [PATCH 01/13] Add stdin/stdout streaming support (upstream PR #321) Lets the CLI participate in unix pipelines: cat harness.yml | wireviz -f s -O - - > harness.svg cat harness.yml | wireviz -f p -O - - > harness.png wireviz -f h -O - harness.yml > harness.html Pass `-` as the input filename to read YAML from stdin, and pass `-` to either `--output-dir` or `--output-name` to write the rendered output to stdout. When writing to stdout exactly one format may be requested. Two refactors enable this: 1. Harness.output() now computes every requested format in memory via `Harness._render()` (graph.pipe(format=...) + embed_svg_images on a string) instead of the previous `graph.render()` -> `.tmp.svg` -> embed_svg_images_file -> rename dance. The caller dispatches the resulting `{fmt: bytes|str}` dict to either files or stdout. 2. generate_html_output() now takes the SVG as a string and returns the HTML string rather than reading a tmp.svg from disk and writing the .html file itself. New optional `output_dir` / `output_name` / `png_b64` parameters preserve the `` and `` template placeholders in file mode and leave them empty/unresolved in stdout mode. Library-level `print()` warnings in DataClasses.py, Harness.py, wv_colors.py, wv_helper.py, and wireviz.py are routed to `sys.stderr` so stdout stays clean of log noise when piped. CLI status banners in wv_cli.py go to stderr for the same reason. Also drops the now-unused `embed_svg_images_file` helper. Ported to master from https://github.com/wireviz/WireViz/pull/321 (originally targeting `dev` by Guillaume Grossetie / @ggrossetie, resolves #320). The PR was rebased / adapted to current master's file_read_text/file_write_text helpers and current Harness.output signature; the in-memory render dict and stdout dispatch are the load-bearing additions. Verification: - All 14 examples + 8 tutorials + 2 demos rebuild without errors. - Deterministic outputs (.gv, .bom.tsv) byte-identical to baseline -> no behavior change in file mode. - Verified stdin->stdout for SVG (text) and PNG (binary). - Multi-format-to-stdout correctly rejected with a clear error. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/DataClasses.py | 9 ++- src/wireviz/Harness.py | 160 +++++++++++++++++++++++++------------ src/wireviz/svgembed.py | 10 --- src/wireviz/wireviz.py | 36 +++++++-- src/wireviz/wv_cli.py | 68 ++++++++++------ src/wireviz/wv_colors.py | 5 +- src/wireviz/wv_helper.py | 7 +- src/wireviz/wv_html.py | 59 ++++++++------ 8 files changed, 231 insertions(+), 123 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 6abfbe2b2..147393728 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import sys from dataclasses import InitVar, dataclass, field from enum import Enum, auto from pathlib import Path @@ -343,8 +344,8 @@ def __post_init__(self) -> None: self.gauge = g if self.gauge_unit is not None: - print( - f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}" + sys.stderr.write( + f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}\n" ) if u.upper() == "AWG": self.gauge_unit = u.upper() @@ -367,8 +368,8 @@ def __post_init__(self) -> None: ) self.length = L if self.length_unit is not None: - print( - f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}" + sys.stderr.write( + f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}\n" ) self.length_unit = u elif not isinstance(self.length, (int, float)): diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index e48a569a7..58e1a2cca 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- import re +import sys from collections import Counter from dataclasses import dataclass from itertools import zip_longest from pathlib import Path -from typing import Any, List, Union +from typing import Any, Dict, List, Optional, Union from graphviz import Graph from wireviz import APP_NAME, APP_URL, __version__, wv_colors @@ -19,7 +20,7 @@ Side, Tweak, ) -from wireviz.svgembed import embed_svg_images, embed_svg_images_file +from wireviz.svgembed import embed_svg_images from wireviz.wv_bom import ( HEADER_MPN, HEADER_PN, @@ -603,12 +604,12 @@ def typecheck(name: str, value: Any, expect: type) -> None: f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry ) if n_subs < 1: - print( - f"Harness.create_graph() warning: {attr} not found in {keyword}!" + sys.stderr.write( + f"Harness.create_graph() warning: {attr} not found in {keyword}!\n" ) elif n_subs > 1: - print( - f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!" + sys.stderr.write( + f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!\n" ) continue @@ -622,8 +623,8 @@ def typecheck(name: str, value: Any, expect: type) -> None: # If attr not found, then append it entry = re.sub(r"\]$", f" {attr}={value}]", entry) elif n_subs > 1: - print( - f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!" + sys.stderr.write( + f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!\n" ) dot.body[i] = entry @@ -670,54 +671,113 @@ def svg(self): # TODO?: Verify xml encoding="utf-8" in SVG? def output( self, - filename: (str, Path), + filename: Optional[Union[str, Path]], + fmt: tuple = ("html", "png", "svg", "tsv"), view: bool = False, cleanup: bool = True, - fmt: tuple = ("html", "png", "svg", "tsv"), + output_dir: Optional[Union[str, Path]] = None, + output_name: Optional[str] = None, ) -> None: - # graphical output - graph = self.graph - svg_already_exists = Path( - f"{filename}.svg" - ).exists() # if SVG already exists, do not delete later - # graphical output - for f in fmt: - if f in ("png", "svg", "html"): - if f == "html": # if HTML format is specified, - f = "svg" # generate SVG for embedding into HTML - # SVG file will be renamed/deleted later - _filename = f"{filename}.tmp" if f == "svg" else filename - # TODO: prevent rendering SVG twice when both SVG and HTML are specified - graph.format = f - graph.render(filename=_filename, view=view, cleanup=cleanup) - # embed images into SVG output - if "svg" in fmt or "html" in fmt: - embed_svg_images_file(f"{filename}.tmp.svg") - # GraphViz output - if "gv" in fmt: - graph.save(filename=f"{filename}.gv") - # BOM output - bomlist = bom_list(self.bom()) - if "tsv" in fmt: - file_write_text(f"{filename}.bom.tsv", tuplelist2tsv(bomlist)) + """Render the harness in the requested formats. + + When ``filename`` is a path, each requested format is written to + ``{filename}.{ext}`` (with ``.bom.tsv`` for the BOM). When + ``filename`` is None, exactly one format must be requested and + its bytes/text are written to stdout — supports piping the CLI + into other tools. + """ + outputs: Dict[str, Union[str, bytes]] = self._render( + fmt, + output_dir=output_dir, + output_name=output_name, + ) + if "csv" in fmt: - # TODO: implement CSV output (preferrably using CSV library) - print("CSV output is not yet supported") - # HTML output - if "html" in fmt: - generate_html_output( - filename, bomlist, self.metadata, self.options, self.source_path - ) - # PDF output + # 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 - print("PDF output is not yet supported") - # delete SVG if not needed - if "html" in fmt and not "svg" in fmt: - # SVG file was just needed to generate HTML - Path(f"{filename}.tmp.svg").unlink() - elif "svg" in fmt: - Path(f"{filename}.tmp.svg").replace(f"{filename}.svg") + 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 + for f in fmt: + content = outputs.get(f) + if content is None: + continue + if isinstance(content, (bytes, bytearray)): + sys.stdout.buffer.write(content) + else: + sys.stdout.write(content) + return + + suffix_map = {"tsv": "bom.tsv"} + for f, content in outputs.items(): + ext = suffix_map.get(f, f) + out_path = f"{filename}.{ext}" + if isinstance(content, (bytes, bytearray)): + Path(out_path).write_bytes(content) + else: + file_write_text(out_path, content) + + def _render( + self, + fmt: tuple, + output_dir: Optional[Union[str, Path]] = None, + output_name: Optional[str] = None, + ) -> Dict[str, Union[str, bytes]]: + """Produce in-memory representations of each requested format. + + 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. + """ + import base64 + + graph = self.graph + outputs: Dict[str, Union[str, bytes]] = {} + + svg_str: Optional[str] = None + if "svg" in fmt or "html" in fmt: + svg_str = embed_svg_images( + graph.pipe(format="svg").decode("utf-8"), Path.cwd() + ) + if "svg" in fmt: + outputs["svg"] = svg_str + + png_bytes: Optional[bytes] = None + if "png" in fmt: + png_bytes = graph.pipe(format="png") + outputs["png"] = png_bytes + + if "gv" in fmt: + outputs["gv"] = graph.source + + if "tsv" in fmt or "html" in fmt: + bomlist = bom_list(self.bom()) + if "tsv" in fmt: + outputs["tsv"] = tuplelist2tsv(bomlist) + if "html" in fmt: + # Inline PNG as base64 in the HTML only when the PNG was + # rendered in this same call; otherwise let the template + # fall back to reading {output_dir}/{output_name}.png. + png_b64 = ( + f"data:image/png;base64, {base64.b64encode(png_bytes).decode('utf-8')}" + if png_bytes is not None + else None + ) + outputs["html"] = generate_html_output( + svg_str, + bomlist, + self.metadata, + self.options, + output_dir=output_dir, + output_name=output_name, + png_b64=png_b64, + source_path=self.source_path, + ) + + return outputs def bom(self): if not self._bom: diff --git a/src/wireviz/svgembed.py b/src/wireviz/svgembed.py index f9511fda5..3e33f631c 100644 --- a/src/wireviz/svgembed.py +++ b/src/wireviz/svgembed.py @@ -54,13 +54,3 @@ def get_mime_subtype(filename: Union[str, Path]) -> str: return mime_subtype -def embed_svg_images_file( - filename_in: Union[str, Path], overwrite: bool = True -) -> None: - filename_in = Path(filename_in).resolve() - filename_out = filename_in.with_suffix(".b64.svg") - filename_out.write_text( # TODO?: Verify xml encoding="utf-8" in SVG? - embed_svg_images(filename_in.read_text(), filename_in.parent) - ) # TODO: Use encoding="utf-8" in both read_text() and write_text() - if overwrite: - filename_out.replace(filename_in) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 78ff0caec..d131aaf7c 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -94,11 +94,18 @@ def parse( raise TypeError( f"Expected a dict as top-level YAML input, but got: {type(yaml_data)}" ) - if output_formats: + write_to_stdout = ( + output_formats and (str(output_dir) == "-" or str(output_name) == "-") + ) + if output_formats and not write_to_stdout: # need to write data to file, determine output directory and filename output_dir = _get_output_dir(yaml_file, output_dir) output_name = _get_output_name(yaml_file, output_name) output_file = output_dir / output_name + else: + output_dir = None + output_name = None + output_file = None if yaml_file: # if reading from file, ensure that input file's parent directory is included in image_paths @@ -397,10 +404,10 @@ def alternate_type(): # flip between connector and cable/arrow used_components = set(designators_and_templates.values()) forgotten_components = [c for c in proposed_components if not c in used_components] if len(forgotten_components) > 0: - print( - "Warning: The following components are not referenced in any connection set:" + sys.stderr.write( + "Warning: The following components are not referenced in any connection set:\n" ) - print(", ".join(forgotten_components)) + sys.stderr.write(", ".join(forgotten_components) + "\n") # harness population completed ============================================= @@ -409,7 +416,20 @@ def alternate_type(): # flip between connector and cable/arrow harness.add_bom_item(line) if output_formats: - harness.output(filename=output_file, fmt=output_formats, view=False) + if write_to_stdout: + if len(output_formats) != 1: + raise Exception( + "Exactly one output format must be specified when writing to stdout." + ) + harness.output(filename=None, fmt=output_formats, view=False) + else: + harness.output( + filename=output_file, + fmt=output_formats, + view=False, + output_dir=output_dir, + output_name=output_name, + ) if return_types: returns = [] @@ -447,8 +467,8 @@ def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path): from errno import EINVAL, ENAMETOOLONG if type(e) is OSError and e.errno not in (EINVAL, ENAMETOOLONG, None): - print( - f"OSError(errno={e.errno}) in Python {sys.version} at {platform.platform()}" + sys.stderr.write( + f"OSError(errno={e.errno}) in Python {sys.version} at {platform.platform()}\n" ) raise e # file does not exist; assume inp is a YAML string @@ -485,7 +505,7 @@ def _get_output_name(input_file: Path, default_output_name: Path) -> str: def main(): - print("When running from the command line, please use wv_cli.py instead.") + sys.stderr.write("When running from the command line, please use wv_cli.py instead.\n") if __name__ == "__main__": diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index 1d671980d..8e40c2da1 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -74,9 +74,12 @@ def wireviz(file, format, prepend, output_dir, output_name, version): """ Parses the provided FILE and generates the specified outputs. + + Pass FILE as ``-`` to read YAML from stdin, and pass ``-`` to either + --output-dir or --output-name to write a single rendered format to + stdout (e.g. ``cat harness.yml | wireviz -f s -O - -``). """ - print() - print(f"{APP_NAME} {__version__}") + sys.stderr.write(f"\n{APP_NAME} {__version__}\n") if version: return # print version number only and exit @@ -88,20 +91,28 @@ def wireviz(file, format, prepend, output_dir, output_name, version): else: filepaths = list(file) - # determine output formats + # determine output formats (preserve user-given order, dedup) output_formats = [] for code in format: if code in format_codes: - output_formats.append(format_codes[code]) + fmt = format_codes[code] + if fmt not in output_formats: + output_formats.append(fmt) else: raise Exception(f"Unknown output format: {code}") - output_formats = tuple(sorted(set(output_formats))) + output_formats = tuple(output_formats) output_formats_str = ( f'[{"|".join(output_formats)}]' if len(output_formats) > 1 else output_formats[0] ) + write_to_stdout = str(output_dir) == "-" or str(output_name) == "-" + if write_to_stdout and len(output_formats) != 1: + raise Exception( + "Exactly one output format (-f) must be specified when writing to stdout." + ) + # check prepend file if len(prepend) > 0: prepend_input = "" @@ -109,35 +120,44 @@ def wireviz(file, format, prepend, output_dir, output_name, version): prepend_file = Path(prepend_file) if not prepend_file.exists(): raise Exception(f"File does not exist:\n{prepend_file}") - print("Prepend file:", prepend_file) + sys.stderr.write(f"Prepend file: {prepend_file}\n") prepend_input += file_read_text(prepend_file) + "\n" else: prepend_input = "" - # run WireVIz on each input file - for file in filepaths: - file = Path(file) - if not file.exists(): - raise Exception(f"File does not exist:\n{file}") - - # file_out = file.with_suffix("") if not output_file else output_file - _output_dir = file.parent if not output_dir else output_dir - _output_name = file.stem if not output_name else output_name + # run WireViz on each input file (or once on stdin) + if not filepaths: + filepaths = ["-"] - print("Input file: ", file) - print( - "Output file: ", f"{Path(_output_dir / _output_name)}.{output_formats_str}" - ) + for file in filepaths: + if str(file) == "-": + yaml_input = prepend_input + sys.stdin.read() + image_paths = set() + sys.stderr.write("Input: \n") + _output_dir = output_dir if output_dir else "-" + _output_name = output_name if output_name else "stdin" + else: + file = Path(file) + if not file.exists(): + raise Exception(f"File does not exist:\n{file}") - yaml_input = file_read_text(file) - file_dir = file.parent + yaml_input = prepend_input + file_read_text(file) + 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 - yaml_input = prepend_input + yaml_input - image_paths = {file_dir} for p in prepend: image_paths.add(Path(p).parent) + if write_to_stdout: + sys.stderr.write(f"Output: .{output_formats_str}\n") + else: + sys.stderr.write( + f"Output file: {Path(_output_dir) / _output_name}.{output_formats_str}\n" + ) + wv.parse( yaml_input, output_formats=output_formats, @@ -147,7 +167,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version): source_path=file, ) - print() + sys.stderr.write("\n") if __name__ == "__main__": diff --git a/src/wireviz/wv_colors.py b/src/wireviz/wv_colors.py index 62957f98b..ea254fab2 100644 --- a/src/wireviz/wv_colors.py +++ b/src/wireviz/wv_colors.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import sys from typing import Dict, List COLOR_CODES = { @@ -138,7 +139,7 @@ def get_color_hex(input: Colors, pad: bool = False) -> List[str]: if c[0] != "#" or not all(d in _hex_digits for d in c[1:]): if c != input: c += f" in input: {input}" - print(f"Invalid hex color: {c}") + sys.stderr.write(f"Invalid hex color: {c}\n") output[i] = color_default else: # Color name(s) @@ -148,7 +149,7 @@ def lookup(c: str) -> str: except KeyError: if c != input: c += f" in input: {input}" - print(f"Unknown color name: {c}") + sys.stderr.write(f"Unknown color name: {c}\n") return color_default output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)] diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 89fb9215e..ecc0c00fe 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import re +import sys from pathlib import Path from typing import Dict, List @@ -160,10 +161,12 @@ def aspect_ratio(image_src): with Image.open(image_src) as image: 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}") + sys.stderr.write( + f"aspect_ratio(): Invalid image size {image.width} x {image.height}\n" + ) # ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally. except Exception as error: - print(f"aspect_ratio(): {type(error).__name__}: {error}") + sys.stderr.write(f"aspect_ratio(): {type(error).__name__}: {error}\n") return 1 # Assume 1:1 when unable to read actual image size diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index c22832ebd..4a76d65b0 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -10,36 +10,40 @@ from wireviz.wv_gv_html import html_line_breaks from wireviz.wv_helper import ( file_read_text, - file_write_text, flatten2d, smart_file_resolve, ) def generate_html_output( - filename: Union[str, Path], + svg_input: Union[str, None], bom_list: List[List[str]], metadata: Metadata, options: Options, - source: Union[str, Path, None] = None, -): + output_dir: Union[str, Path, None] = None, + output_name: Union[str, None] = None, + png_b64: Union[str, None] = None, + source_path: Union[str, Path, None] = None, +) -> str: # load HTML template templatename = metadata.get("template", {}).get("name") - template_search_paths = [ - Path(filename).parent, - Path(__file__).parent / "templates", - ] - if source is not None: - template_search_paths.insert(0, Path(source).parent) + builtin_template_dir = Path(__file__).parent / "templates" if templatename: - # if relative path to template was provided, check the YAML source's - # directory first, then the output directory, then the built-in templates + # custom template lookup order: directory of the input YAML + # (source_path), then the output directory, then the 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) templatefile = smart_file_resolve( - f"{templatename}.html", template_search_paths + f"{templatename}.html", + search_paths, ) else: # fall back to built-in simple template if no template was provided - templatefile = Path(__file__).parent / "templates/simple.html" + templatefile = builtin_template_dir / "simple.html" html = file_read_text(templatefile) # TODO?: Warn if unexpected meta charset? @@ -48,7 +52,7 @@ def svgdata() -> str: return re.sub( # TODO?: Verify xml encoding="utf-8" in SVG? "^<[?]xml [^?>]*[?]>[^<]*]*>", "", - file_read_text(f"{filename}.tmp.svg"), + svg_input or "", 1, ) @@ -82,13 +86,20 @@ def svgdata() -> str: + "\n" ) + if output_dir is not None and output_name is not None: + full_filename = str(Path(output_dir) / output_name) + filename_stem = output_name + else: + full_filename = "" + filename_stem = "" + # prepare simple replacements replacements = { "": f"{APP_NAME} {__version__} - {APP_URL}", "": options.fontname, "": wv_colors.translate_color(options.bgcolor, "hex"), - "": str(filename), - "": Path(filename).stem, + "": full_filename, + "": filename_stem, "": bom_html, "": bom_html_reversed, "": "1", # TODO: handle multi-page documents @@ -104,9 +115,13 @@ def replacement_if_used(key: str, func: Callable[[], str]) -> None: replacements[key] = func() replacement_if_used("", svgdata) - replacement_if_used( - "", lambda: data_URI_base64(f"{filename}.png") - ) + if png_b64 is not None: + replacement_if_used("", lambda: png_b64) + elif full_filename: + replacement_if_used( + "", + lambda: data_URI_base64(f"{full_filename}.png"), + ) # prepare metadata replacements if metadata: @@ -132,6 +147,4 @@ def replacement_if_used(key: str, func: Callable[[], str]) -> None: replacements_sorted = sorted(replacements, key=len, reverse=True) replacements_escaped = map(re.escape, replacements_sorted) pattern = re.compile("|".join(replacements_escaped)) - html = pattern.sub(lambda match: replacements[match.group(0)], html) - - file_write_text(f"{filename}.html", html) + return pattern.sub(lambda match: replacements[match.group(0)], html) From c8d9469b297822076c15134531142bc4f679a8a9 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 19:23:24 -0400 Subject: [PATCH 02/13] Address review feedback on the stdin/stdout port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from gemini-code-assist's review of https://github.com/ClassicMiniDIY/WireViz/pull/2: * Harness.py: move ``import base64`` to the module-level imports rather than the function-local position inside ``_render()`` (PEP 8). * Harness.py: ``output()`` and ``_render()`` now accept fmt as ``Union[str, Tuple[str, ...], List[str]]`` and normalize a bare string to a one-tuple before iterating. Previously a programmatic caller passing ``output_formats="svg"`` to ``wireviz.parse()`` would cause ``for f in fmt:`` to iterate over the characters ``s``, ``v``, ``g`` — a pre-existing latent bug that became reachable through this PR's refactor. * Harness.py: when ``self.source_path`` is set, embed_svg_images now resolves relative paths against the YAML source's parent directory rather than ``Path.cwd()``. In normal use wireviz.parse() rewrites relative image paths to absolute during YAML parse, so this only matters for already-rendered Harness objects or when a tweak injects a post-parse relative path — but it's the conceptually correct base path. * wireviz.py: ``raise ValueError`` instead of generic ``Exception`` for the "exactly one output format when writing to stdout" check — signals to programmatic callers that this is an argument-validity error, not an internal failure. * wv_cli.py: ``raise click.UsageError`` instead of generic ``Exception`` for the same check on the CLI side. Click renders UsageError with a "Try 'wireviz -h' for help." footer instead of a Python traceback, which is the right UX. Verified with build_examples.py: deterministic outputs (.gv, .bom.tsv) remain byte-identical to the baseline; the ``output_formats="svg"`` programmatic path now produces a valid SVG; multi-format-to-stdout is still rejected, now with a clean Click-formatted error. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wireviz/Harness.py | 25 +++++++++++++++++++------ src/wireviz/wireviz.py | 2 +- src/wireviz/wv_cli.py | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 58e1a2cca..1efef9fc4 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- +import base64 import re import sys from collections import Counter from dataclasses import dataclass from itertools import zip_longest from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from graphviz import Graph from wireviz import APP_NAME, APP_URL, __version__, wv_colors @@ -672,7 +673,7 @@ def svg(self): # TODO?: Verify xml encoding="utf-8" in SVG? def output( self, filename: Optional[Union[str, Path]], - fmt: tuple = ("html", "png", "svg", "tsv"), + fmt: Union[str, Tuple[str, ...], List[str]] = ("html", "png", "svg", "tsv"), view: bool = False, cleanup: bool = True, output_dir: Optional[Union[str, Path]] = None, @@ -686,6 +687,8 @@ def output( its bytes/text are written to stdout — supports piping the CLI into other tools. """ + if isinstance(fmt, str): + fmt = (fmt,) outputs: Dict[str, Union[str, bytes]] = self._render( fmt, output_dir=output_dir, @@ -722,7 +725,7 @@ def output( def _render( self, - fmt: tuple, + fmt: Union[str, Tuple[str, ...], List[str]], output_dir: Optional[Union[str, Path]] = None, output_name: Optional[str] = None, ) -> Dict[str, Union[str, bytes]]: @@ -732,15 +735,25 @@ def _render( + temporary files so the caller can write files OR pipe to stdout without the SVG-file roundtrip the previous implementation used. """ - import base64 - + if isinstance(fmt, str): + fmt = (fmt,) graph = self.graph outputs: Dict[str, Union[str, bytes]] = {} svg_str: Optional[str] = None if "svg" in fmt or "html" in fmt: + # Resolve relative references against the YAML + # source's directory when known; fall back to cwd. (In practice + # wireviz.parse() rewrites relative image paths to absolute + # during YAML parse, so this base path only matters for SVG + # produced from already-rendered Harness objects or when a + # tweak injects a post-parse relative path.) + if self.source_path is not None and str(self.source_path) != "-": + base_path: Path = Path(self.source_path).parent + else: + base_path = Path.cwd() svg_str = embed_svg_images( - graph.pipe(format="svg").decode("utf-8"), Path.cwd() + graph.pipe(format="svg").decode("utf-8"), base_path ) if "svg" in fmt: outputs["svg"] = svg_str diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index d131aaf7c..a4d203000 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -418,7 +418,7 @@ def alternate_type(): # flip between connector and cable/arrow if output_formats: if write_to_stdout: if len(output_formats) != 1: - raise Exception( + raise ValueError( "Exactly one output format must be specified when writing to stdout." ) harness.output(filename=None, fmt=output_formats, view=False) diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index 8e40c2da1..ceb25224d 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -109,7 +109,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version): write_to_stdout = str(output_dir) == "-" or str(output_name) == "-" if write_to_stdout and len(output_formats) != 1: - raise Exception( + raise click.UsageError( "Exactly one output format (-f) must be specified when writing to stdout." ) From a8abc2b2a0d593ee9c3fbcec08c1e37ca87a2281 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Mon, 4 May 2026 19:59:01 -0400 Subject: [PATCH 03/13] 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 1efef9fc4..294297af1 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 a4d203000..880eff633 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 ceb25224d..ce298d454 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 4a76d65b0..58edaafb6 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 04/13] 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 2c92fd34b..ae6a85084 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 e5fb6b648..6b97135c2 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 ca788be8d..32e1c70e1 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 8dd0e4c44..a88b8c3f2 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 c0d893882..7c4c863d2 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 486b1e2e1..a729db9d9 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 2db8c5c2a..d15b449f8 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 3dce0bb0e..7f7733d90 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 ee6b656a5..ed67294eb 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 1d7c7e6f0..794f27c96 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 ce6ff89c4..2e79f707e 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 976494a68..8f0a442a6 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 6b859ef25..d666d0b1b 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 f0cb0e910..42e3970ea 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 948831082..989c7d677 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 776e08b28..f68f925ed 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 147393728..9693268d1 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 1efef9fc4..929bedd2b 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 7b53bf807..6a156d2bf 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 7098d9af1..e04758a98 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 741505597..1dbebd455 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 1e5a7421d..550cdabd7 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 4140a139d..18062fc18 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 2976b7d55..b5f43c575 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 d2c45b91c..4cfedf38e 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 761ccb856..b91ac9e19 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 05/13] 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 294297af1..c77f8713e 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 880eff633..de7b7a112 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 06/13] 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 9693268d1..66b92c383 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 929bedd2b..83d9ec75b 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 07/13] 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 c753cae14..ea0eac9ee 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 e54d0f5cc..80d422f9d 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 de7b7a112..0bd770cf1 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 ce298d454..d95154c2b 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 08/13] 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 31d3ef886..e9b4692b4 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 58edaafb6..002fdb132 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 09/13] 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 002fdb132..2393317db 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 10/13] 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 ae6a85084..0811699a8 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 66b92c383..68ec326d5 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 c753cae14..9c0a42ea8 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 11/13] 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 9c0a42ea8..664613629 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 12/13] 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 c753cae14..4784b6bef 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 ce298d454..acb24be08 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 13/13] 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 de7b7a112..6a164f68c 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