diff --git a/docs/syntax.md b/docs/syntax.md index 2c92fd34b..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 @@ -393,6 +398,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 ``` @@ -452,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/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..68ec326d5 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -59,6 +59,12 @@ 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. 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): if not self.bgcolor_node: @@ -73,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 @@ -164,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 @@ -329,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 1efef9fc4..96ea37aa8 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, @@ -30,6 +33,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 @@ -59,6 +63,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.""" @@ -84,9 +133,57 @@ 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 + # 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 {} + 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 + # 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) + + [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)) @@ -168,14 +265,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, - ) # 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", @@ -678,6 +781,8 @@ def output( cleanup: bool = True, 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. @@ -686,6 +791,35 @@ 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. + + 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. + 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. + 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,) @@ -693,14 +827,13 @@ def output( fmt, output_dir=output_dir, output_name=output_name, + template_dir=template_dir, + yaml_source=yaml_source, ) 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 @@ -728,12 +861,34 @@ 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, + yaml_source: 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. + + 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,) @@ -761,8 +916,13 @@ 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 "pdf" in fmt: + outputs["pdf"] = graph.pipe(format="pdf") + if "gv" in fmt: outputs["gv"] = graph.source @@ -788,6 +948,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/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/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/wireviz.py b/src/wireviz/wireviz.py index a4d203000..82a55aec5 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -32,6 +32,8 @@ 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, + embed_yaml: bool = True, ) -> Any: """ This function takes an input, parses it as a WireViz Harness file, @@ -52,7 +54,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 @@ -76,6 +78,22 @@ 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. + 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: @@ -89,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)}" @@ -415,13 +433,20 @@ 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: 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, + yaml_source=yaml_source_for_png, + ) else: harness.output( filename=output_file, @@ -429,6 +454,8 @@ def alternate_type(): # flip between connector and cable/arrow view=False, output_dir=output_dir, output_name=output_name, + template_dir=template_dir, + yaml_source=yaml_source_for_png, ) if return_types: @@ -449,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: @@ -476,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 ceb25224d..ebbca72c9 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 = { @@ -18,7 +19,7 @@ "g": "gv", "h": "html", "p": "png", - # "P": "pdf", + "P": "pdf", "s": "svg", "t": "tsv", } @@ -64,6 +65,20 @@ 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( + "--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", @@ -71,7 +86,9 @@ 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, embed_yaml, version +): """ Parses the provided FILE and generates the specified outputs. @@ -142,9 +159,28 @@ def wireviz(file, format, prepend, output_dir, output_name, version): 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 @@ -165,6 +201,8 @@ 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, + embed_yaml=embed_yaml, ) sys.stderr.write("\n") diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 4a76d65b0..2393317db 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -15,6 +15,21 @@ ) +def _latest_revision(metadata: Metadata) -> str: + """Return the key of the most recently added entry in + ``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 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( svg_input: Union[str, None], bom_list: List[List[str]], @@ -24,19 +39,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, @@ -107,6 +127,7 @@ def svgdata() -> str: "": metadata.get("template", {}).get( "sheetsize", "" ), + "": _latest_revision(metadata), } def replacement_if_used(key: str, func: Callable[[], str]) -> None: 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=<