11# -*- coding: utf-8 -*-
22
3+ import base64
34import re
5+ import sys
46from collections import Counter
57from dataclasses import dataclass
68from itertools import zip_longest
79from pathlib import Path
8- from typing import Any , List , Union
10+ from typing import Any , Dict , List , Optional , Tuple , Union
911
1012from graphviz import Graph
1113from wireviz import APP_NAME , APP_URL , __version__ , wv_colors
1921 Side ,
2022 Tweak ,
2123)
22- from wireviz .svgembed import embed_svg_images , embed_svg_images_file
24+ from wireviz .svgembed import embed_svg_images
2325from wireviz .wv_bom import (
2426 HEADER_MPN ,
2527 HEADER_PN ,
@@ -603,12 +605,12 @@ def typecheck(name: str, value: Any, expect: type) -> None:
603605 f'( +)?{ attr } =("[^"]*"|[^] ]*)(?(1)| *)' , "" , entry
604606 )
605607 if n_subs < 1 :
606- print (
607- f"Harness.create_graph() warning: { attr } not found in { keyword } !"
608+ sys . stderr . write (
609+ f"Harness.create_graph() warning: { attr } not found in { keyword } !\n "
608610 )
609611 elif n_subs > 1 :
610- print (
611- f"Harness.create_graph() warning: { attr } removed { n_subs } times in { keyword } !"
612+ sys . stderr . write (
613+ f"Harness.create_graph() warning: { attr } removed { n_subs } times in { keyword } !\n "
612614 )
613615 continue
614616
@@ -622,8 +624,8 @@ def typecheck(name: str, value: Any, expect: type) -> None:
622624 # If attr not found, then append it
623625 entry = re .sub (r"\]$" , f" { attr } ={ value } ]" , entry )
624626 elif n_subs > 1 :
625- print (
626- f"Harness.create_graph() warning: { attr } overridden { n_subs } times in { keyword } !"
627+ sys . stderr . write (
628+ f"Harness.create_graph() warning: { attr } overridden { n_subs } times in { keyword } !\n "
627629 )
628630
629631 dot .body [i ] = entry
@@ -670,54 +672,125 @@ def svg(self): # TODO?: Verify xml encoding="utf-8" in SVG?
670672
671673 def output (
672674 self ,
673- filename : (str , Path ),
675+ filename : Optional [Union [str , Path ]],
676+ fmt : Union [str , Tuple [str , ...], List [str ]] = ("html" , "png" , "svg" , "tsv" ),
674677 view : bool = False ,
675678 cleanup : bool = True ,
676- fmt : tuple = ("html" , "png" , "svg" , "tsv" ),
679+ output_dir : Optional [Union [str , Path ]] = None ,
680+ output_name : Optional [str ] = None ,
677681 ) -> None :
678- # graphical output
679- graph = self .graph
680- svg_already_exists = Path (
681- f"{ filename } .svg"
682- ).exists () # if SVG already exists, do not delete later
683- # graphical output
684- for f in fmt :
685- if f in ("png" , "svg" , "html" ):
686- if f == "html" : # if HTML format is specified,
687- f = "svg" # generate SVG for embedding into HTML
688- # SVG file will be renamed/deleted later
689- _filename = f"{ filename } .tmp" if f == "svg" else filename
690- # TODO: prevent rendering SVG twice when both SVG and HTML are specified
691- graph .format = f
692- graph .render (filename = _filename , view = view , cleanup = cleanup )
693- # embed images into SVG output
694- if "svg" in fmt or "html" in fmt :
695- embed_svg_images_file (f"{ filename } .tmp.svg" )
696- # GraphViz output
697- if "gv" in fmt :
698- graph .save (filename = f"{ filename } .gv" )
699- # BOM output
700- bomlist = bom_list (self .bom ())
701- if "tsv" in fmt :
702- file_write_text (f"{ filename } .bom.tsv" , tuplelist2tsv (bomlist ))
682+ """Render the harness in the requested formats.
683+
684+ When ``filename`` is a path, each requested format is written to
685+ ``{filename}.{ext}`` (with ``.bom.tsv`` for the BOM). When
686+ ``filename`` is None, exactly one format must be requested and
687+ its bytes/text are written to stdout — supports piping the CLI
688+ into other tools.
689+ """
690+ if isinstance (fmt , str ):
691+ fmt = (fmt ,)
692+ outputs : Dict [str , Union [str , bytes ]] = self ._render (
693+ fmt ,
694+ output_dir = output_dir ,
695+ output_name = output_name ,
696+ )
697+
703698 if "csv" in fmt :
704- # TODO: implement CSV output (preferrably using CSV library)
705- print ("CSV output is not yet supported" )
706- # HTML output
707- if "html" in fmt :
708- generate_html_output (
709- filename , bomlist , self .metadata , self .options , self .source_path
710- )
711- # PDF output
699+ # TODO: implement CSV output (preferably using CSV library)
700+ sys .stderr .write ("CSV output is not yet supported\n " )
712701 if "pdf" in fmt :
713702 # TODO: implement PDF output
714- print ("PDF output is not yet supported" )
715- # delete SVG if not needed
716- if "html" in fmt and not "svg" in fmt :
717- # SVG file was just needed to generate HTML
718- Path (f"{ filename } .tmp.svg" ).unlink ()
719- elif "svg" in fmt :
720- Path (f"{ filename } .tmp.svg" ).replace (f"{ filename } .svg" )
703+ sys .stderr .write ("PDF output is not yet supported\n " )
704+
705+ if filename is None :
706+ # stdout mode — emit each rendered format in the user-requested order
707+ for f in fmt :
708+ content = outputs .get (f )
709+ if content is None :
710+ continue
711+ if isinstance (content , (bytes , bytearray )):
712+ sys .stdout .buffer .write (content )
713+ else :
714+ sys .stdout .write (content )
715+ return
716+
717+ suffix_map = {"tsv" : "bom.tsv" }
718+ for f , content in outputs .items ():
719+ ext = suffix_map .get (f , f )
720+ out_path = f"{ filename } .{ ext } "
721+ if isinstance (content , (bytes , bytearray )):
722+ Path (out_path ).write_bytes (content )
723+ else :
724+ file_write_text (out_path , content )
725+
726+ def _render (
727+ self ,
728+ fmt : Union [str , Tuple [str , ...], List [str ]],
729+ output_dir : Optional [Union [str , Path ]] = None ,
730+ output_name : Optional [str ] = None ,
731+ ) -> Dict [str , Union [str , bytes ]]:
732+ """Produce in-memory representations of each requested format.
733+
734+ Pipes graphviz once per binary output rather than via ``render()``
735+ + temporary files so the caller can write files OR pipe to stdout
736+ without the SVG-file roundtrip the previous implementation used.
737+ """
738+ if isinstance (fmt , str ):
739+ fmt = (fmt ,)
740+ graph = self .graph
741+ outputs : Dict [str , Union [str , bytes ]] = {}
742+
743+ svg_str : Optional [str ] = None
744+ if "svg" in fmt or "html" in fmt :
745+ # Resolve relative <image src=...> references against the YAML
746+ # source's directory when known; fall back to cwd. (In practice
747+ # wireviz.parse() rewrites relative image paths to absolute
748+ # during YAML parse, so this base path only matters for SVG
749+ # produced from already-rendered Harness objects or when a
750+ # tweak injects a post-parse relative path.)
751+ if self .source_path is not None and str (self .source_path ) != "-" :
752+ base_path : Path = Path (self .source_path ).parent
753+ else :
754+ base_path = Path .cwd ()
755+ svg_str = embed_svg_images (
756+ graph .pipe (format = "svg" ).decode ("utf-8" ), base_path
757+ )
758+ if "svg" in fmt :
759+ outputs ["svg" ] = svg_str
760+
761+ png_bytes : Optional [bytes ] = None
762+ if "png" in fmt :
763+ png_bytes = graph .pipe (format = "png" )
764+ outputs ["png" ] = png_bytes
765+
766+ if "gv" in fmt :
767+ outputs ["gv" ] = graph .source
768+
769+ if "tsv" in fmt or "html" in fmt :
770+ bomlist = bom_list (self .bom ())
771+ if "tsv" in fmt :
772+ outputs ["tsv" ] = tuplelist2tsv (bomlist )
773+ if "html" in fmt :
774+ # Inline PNG as base64 in the HTML only when the PNG was
775+ # rendered in this same call; otherwise let the template
776+ # fall back to reading {output_dir}/{output_name}.png.
777+ png_b64 = (
778+ f"data:image/png;base64, { base64 .b64encode (png_bytes ).decode ('utf-8' )} "
779+ if png_bytes is not None
780+ else None
781+ )
782+ outputs ["html" ] = generate_html_output (
783+ svg_str ,
784+ bomlist ,
785+ self .metadata ,
786+ self .options ,
787+ output_dir = output_dir ,
788+ output_name = output_name ,
789+ png_b64 = png_b64 ,
790+ source_path = self .source_path ,
791+ )
792+
793+ return outputs
721794
722795 def bom (self ):
723796 if not self ._bom :
0 commit comments