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 6abfbe2b2..68ec326d5 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 @@ -58,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: @@ -72,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 @@ -163,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 @@ -328,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: @@ -343,8 +357,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 +381,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..96ea37aa8 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,13 +1,18 @@ # -*- coding: utf-8 -*- +import base64 +import io 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, 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, @@ -19,7 +24,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, @@ -28,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 @@ -57,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.""" @@ -82,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)) @@ -166,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", @@ -603,12 +708,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 +727,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 +775,183 @@ def svg(self): # TODO?: Verify xml encoding="utf-8" in SVG? def output( self, - filename: (str, Path), + filename: Optional[Union[str, Path]], + fmt: Union[str, Tuple[str, ...], List[str]] = ("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, + template_dir: Optional[Union[str, Path]] = None, + yaml_source: Optional[str] = None, ) -> None: - # graphical output + """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. + + 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,) + outputs: Dict[str, Union[str, bytes]] = self._render( + 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 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: 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,) 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 + outputs: Dict[str, Union[str, bytes]] = {} + + svg_str: Optional[str] = None 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)) - 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 + # 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"), base_path ) - # PDF output + if "svg" in fmt: + outputs["svg"] = svg_str + + 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: - # 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") + outputs["pdf"] = graph.pipe(format="pdf") + + 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, + template_dir=template_dir, + ) + + return outputs def bom(self): if not self._bom: 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/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/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 78ff0caec..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,16 +107,23 @@ 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)}" ) - 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 +422,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 ============================================= @@ -408,8 +433,30 @@ 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: - harness.output(filename=output_file, fmt=output_formats, view=False) + 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, + template_dir=template_dir, + yaml_source=yaml_source_for_png, + ) + else: + harness.output( + filename=output_file, + fmt=output_formats, + view=False, + output_dir=output_dir, + output_name=output_name, + template_dir=template_dir, + yaml_source=yaml_source_for_png, + ) if return_types: returns = [] @@ -429,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: @@ -447,8 +496,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 @@ -456,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: @@ -485,7 +536,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..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,12 +86,17 @@ 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. + + 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 +108,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 click.UsageError( + "Exactly one output format (-f) must be specified when writing to stdout." + ) + # check prepend file if len(prepend) > 0: prepend_input = "" @@ -109,35 +137,63 @@ 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 - - print("Input file: ", file) - print( - "Output file: ", f"{Path(_output_dir / _output_name)}.{output_formats_str}" - ) + # run WireViz on each input file (or once on stdin) + if not filepaths: + filepaths = ["-"] - yaml_input = file_read_text(file) - file_dir = file.parent + 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}") + + 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} + _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, @@ -145,9 +201,11 @@ 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, ) - 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..2393317db 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -10,36 +10,60 @@ 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 _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( - 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, + template_dir: 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, 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", 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 +72,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 +106,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 @@ -96,6 +127,7 @@ def svgdata() -> str: "": metadata.get("template", {}).get( "sheetsize", "" ), + "": _latest_revision(metadata), } def replacement_if_used(key: str, func: Callable[[], str]) -> None: @@ -104,9 +136,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 +168,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) 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=<