Rename modules, adjust imports, move build_examples.py
This commit is contained in:
parent
7111a3375f
commit
fb91be402a
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install .
|
pip install .
|
||||||
- name: Create Examples
|
- name: Create Examples
|
||||||
run: PYTHONPATH=$(pwd)/src:$PYTHONPATH cd src/wireviz/ && python build_examples.py
|
run: PYTHONPATH=$(pwd)/src:$PYTHONPATH && python src/wireviz/tools/build_examples.py
|
||||||
- name: Upload examples, demos, and tutorials
|
- name: Upload examples, demos, and tutorials
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
mime_subtype_replacements = {"jpg": "jpeg", "tif": "tiff"}
|
|
||||||
|
|
||||||
|
|
||||||
def embed_svg_images(svg_in: str, base_path: Union[str, Path] = Path.cwd()) -> str:
|
|
||||||
images_b64 = {} # cache of base64-encoded images
|
|
||||||
|
|
||||||
def image_tag(pre: str, url: str, post: str) -> str:
|
|
||||||
return f'<image{pre} xlink:href="{url}"{post}>'
|
|
||||||
|
|
||||||
def replace(match: re.Match) -> str:
|
|
||||||
imgurl = match["URL"]
|
|
||||||
if not imgurl in images_b64: # only encode/cache every unique URL once
|
|
||||||
imgurl_abs = (Path(base_path) / imgurl).resolve()
|
|
||||||
image = imgurl_abs.read_bytes()
|
|
||||||
images_b64[imgurl] = base64.b64encode(image).decode("utf-8")
|
|
||||||
return image_tag(
|
|
||||||
match["PRE"] or "",
|
|
||||||
f"data:image/{get_mime_subtype(imgurl)};base64, {images_b64[imgurl]}",
|
|
||||||
match["POST"] or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
pattern = re.compile(
|
|
||||||
image_tag(r"(?P<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return pattern.sub(replace, svg_in)
|
|
||||||
|
|
||||||
|
|
||||||
def get_mime_subtype(filename: Union[str, Path]) -> str:
|
|
||||||
mime_subtype = Path(filename).suffix.lstrip(".").lower()
|
|
||||||
if mime_subtype in mime_subtype_replacements:
|
|
||||||
mime_subtype = mime_subtype_replacements[mime_subtype]
|
|
||||||
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(
|
|
||||||
embed_svg_images(filename_in.read_text(), filename_in.parent)
|
|
||||||
)
|
|
||||||
if overwrite:
|
|
||||||
filename_out.replace(filename_in)
|
|
||||||
@ -7,13 +7,12 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
script_path = Path(__file__).absolute()
|
script_path = Path(__file__).absolute()
|
||||||
|
sys.path.insert(0, str(script_path.parent.parent.parent)) # to find wireviz module
|
||||||
sys.path.insert(0, str(script_path.parent.parent)) # to find wireviz module
|
|
||||||
from wv_helper import open_file_append, open_file_read, open_file_write
|
|
||||||
|
|
||||||
from wireviz import APP_NAME, __version__, wireviz
|
from wireviz import APP_NAME, __version__, wireviz
|
||||||
|
from wireviz.wv_utils import open_file_append, open_file_read, open_file_write
|
||||||
|
|
||||||
dir = script_path.parent.parent.parent
|
dir = script_path.parent.parent.parent.parent
|
||||||
readme = "readme.md"
|
readme = "readme.md"
|
||||||
groups = {
|
groups = {
|
||||||
"examples": {
|
"examples": {
|
||||||
@ -10,9 +10,9 @@ import yaml
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
|
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
|
||||||
|
|
||||||
from wireviz.DataClasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
|
from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
|
||||||
from wireviz.Harness import Harness
|
from wireviz.wv_harness import Harness
|
||||||
from wireviz.wv_helper import (
|
from wireviz.wv_utils import (
|
||||||
expand,
|
expand,
|
||||||
get_single_key_and_value,
|
get_single_key_and_value,
|
||||||
is_arrow,
|
is_arrow,
|
||||||
@ -396,20 +396,12 @@ def parse(
|
|||||||
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
|
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
|
||||||
# determine whether inp is a file path, a YAML string, or a Dict
|
# determine whether inp is a file path, a YAML string, or a Dict
|
||||||
if not isinstance(inp, Dict): # received a str or a Path
|
if not isinstance(inp, Dict): # received a str or a Path
|
||||||
try:
|
if isinstance(inp, Path) or (isinstance(inp, str) and not "\n" in inp):
|
||||||
yaml_path = Path(inp).expanduser().resolve(strict=True)
|
yaml_path = Path(inp).expanduser().resolve(strict=True)
|
||||||
# if no FileNotFoundError exception happens, get file contents
|
|
||||||
yaml_str = open_file_read(yaml_path).read()
|
yaml_str = open_file_read(yaml_path).read()
|
||||||
except (FileNotFoundError, OSError) as e:
|
else:
|
||||||
# if inp is a long YAML string, Pathlib will raise OSError: [errno.ENAMETOOLONG]
|
|
||||||
# when trying to expand and resolve it as a path.
|
|
||||||
# Catch this error, but raise any others
|
|
||||||
from errno import ENAMETOOLONG
|
|
||||||
if type(e) is OSError and e.errno != ENAMETOOLONG:
|
|
||||||
raise e
|
|
||||||
# file does not exist; assume inp is a YAML string
|
|
||||||
yaml_str = inp
|
|
||||||
yaml_path = None
|
yaml_path = None
|
||||||
|
yaml_str = inp
|
||||||
yaml_data = yaml.safe_load(yaml_str)
|
yaml_data = yaml.safe_load(yaml_str)
|
||||||
else:
|
else:
|
||||||
# received a Dict, use as-is
|
# received a Dict, use as-is
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from wireviz.wv_helper import html_line_breaks
|
from wireviz.wv_utils import html_line_breaks
|
||||||
|
|
||||||
BOM_HASH_FIELDS = "description unit partnumbers"
|
BOM_HASH_FIELDS = "description unit partnumbers"
|
||||||
BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)
|
BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import wireviz.wireviz as wv
|
import wireviz.wireviz as wv
|
||||||
from wireviz import APP_NAME, __version__
|
from wireviz import APP_NAME, __version__
|
||||||
from wireviz.wv_helper import open_file_read
|
from wireviz.wv_utils import open_file_read
|
||||||
|
|
||||||
format_codes = {
|
format_codes = {
|
||||||
"c": "csv",
|
"c": "csv",
|
||||||
@ -23,10 +23,11 @@ format_codes = {
|
|||||||
"t": "tsv",
|
"t": "tsv",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
epilog = (
|
epilog = (
|
||||||
"The -f or --format option accepts a string containing one or more of the "
|
"The -f or --format option accepts a string containing one or more of the "
|
||||||
"following characters to specify which file types to output:\n"
|
"following characters to specify which file types to output:\n"
|
||||||
f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
|
+ f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from wireviz.wv_colors import (
|
|||||||
SingleColor,
|
SingleColor,
|
||||||
get_color_by_colorcode_index,
|
get_color_by_colorcode_index,
|
||||||
)
|
)
|
||||||
from wireviz.wv_helper import aspect_ratio, awg_equiv, mm2_equiv, remove_links
|
from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links
|
||||||
|
|
||||||
# Each type alias have their legal values described in comments
|
# Each type alias have their legal values described in comments
|
||||||
# - validation might be implemented in the future
|
# - validation might be implemented in the future
|
||||||
@ -5,7 +5,8 @@ from itertools import zip_longest
|
|||||||
from typing import Any, List, Union
|
from typing import Any, List, Union
|
||||||
|
|
||||||
from wireviz import APP_NAME, APP_URL, __version__
|
from wireviz import APP_NAME, APP_URL, __version__
|
||||||
from wireviz.DataClasses import (
|
from wireviz.wv_bom import partnumbers_to_list
|
||||||
|
from wireviz.wv_dataclasses import (
|
||||||
ArrowDirection,
|
ArrowDirection,
|
||||||
ArrowWeight,
|
ArrowWeight,
|
||||||
Cable,
|
Cable,
|
||||||
@ -18,9 +19,8 @@ from wireviz.DataClasses import (
|
|||||||
ShieldClass,
|
ShieldClass,
|
||||||
WireClass,
|
WireClass,
|
||||||
)
|
)
|
||||||
from wireviz.wv_bom import partnumbers_to_list
|
from wireviz.wv_html import Img, Table, Td, Tr
|
||||||
from wireviz.wv_helper import html_line_breaks, remove_links
|
from wireviz.wv_utils import html_line_breaks, remove_links
|
||||||
from wireviz.wv_table_util import * # TODO: explicitly import each needed tag later
|
|
||||||
|
|
||||||
|
|
||||||
def gv_node_component(component: Component) -> Table:
|
def gv_node_component(component: Component) -> Table:
|
||||||
@ -8,7 +8,7 @@ from typing import List
|
|||||||
from graphviz import Graph
|
from graphviz import Graph
|
||||||
|
|
||||||
import wireviz.wv_colors
|
import wireviz.wv_colors
|
||||||
from wireviz.DataClasses import (
|
from wireviz.wv_dataclasses import (
|
||||||
AUTOGENERATED_PREFIX,
|
AUTOGENERATED_PREFIX,
|
||||||
AdditionalComponent,
|
AdditionalComponent,
|
||||||
Arrow,
|
Arrow,
|
||||||
@ -23,8 +23,7 @@ from wireviz.DataClasses import (
|
|||||||
Side,
|
Side,
|
||||||
Tweak,
|
Tweak,
|
||||||
)
|
)
|
||||||
from wireviz.svgembed import embed_svg_images_file
|
from wireviz.wv_graphviz import (
|
||||||
from wireviz.wv_gv_html import (
|
|
||||||
apply_dot_tweaks,
|
apply_dot_tweaks,
|
||||||
calculate_node_bgcolor,
|
calculate_node_bgcolor,
|
||||||
gv_connector_loops,
|
gv_connector_loops,
|
||||||
@ -34,8 +33,8 @@ from wireviz.wv_gv_html import (
|
|||||||
parse_arrow_str,
|
parse_arrow_str,
|
||||||
set_dot_basics,
|
set_dot_basics,
|
||||||
)
|
)
|
||||||
from wireviz.wv_helper import open_file_write, tuplelist2tsv
|
from wireviz.wv_output import embed_svg_images_file, generate_html_output
|
||||||
from wireviz.wv_html import generate_html_output
|
from wireviz.wv_utils import open_file_write, tuplelist2tsv
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -1,120 +1,125 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import re
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Union
|
from typing import Dict
|
||||||
|
|
||||||
from wireviz import APP_NAME, APP_URL, __version__, wv_colors
|
indent_count = 1
|
||||||
from wireviz.DataClasses import Metadata, Options
|
|
||||||
from wireviz.wv_helper import (
|
|
||||||
flatten2d,
|
|
||||||
html_line_breaks,
|
|
||||||
open_file_read,
|
|
||||||
open_file_write,
|
|
||||||
smart_file_resolve,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_html_output(
|
class Attribs(Dict):
|
||||||
filename: Union[str, Path],
|
def __repr__(self):
|
||||||
bom_list: List[List[str]],
|
if len(self) == 0:
|
||||||
metadata: Metadata,
|
return ""
|
||||||
options: Options,
|
|
||||||
):
|
|
||||||
|
|
||||||
# load HTML template
|
html = []
|
||||||
templatename = metadata.get("template", {}).get("name")
|
for k, v in self.items():
|
||||||
if templatename:
|
if v is not None:
|
||||||
# if relative path to template was provided,
|
html.append(f' {k}="{v}"')
|
||||||
# check directory of YAML file first, fall back to built-in template directory
|
# else:
|
||||||
templatefile = smart_file_resolve(
|
# html.append(f" {k}")
|
||||||
f"{templatename}.html",
|
return "".join(html)
|
||||||
[Path(filename).parent, Path(__file__).parent / "templates"],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# fall back to built-in simple template if no template was provided
|
|
||||||
templatefile = Path(__file__).parent / "templates/simple.html"
|
|
||||||
|
|
||||||
html = open_file_read(templatefile).read()
|
|
||||||
|
|
||||||
# embed SVG diagram
|
@dataclass
|
||||||
with open_file_read(f"{filename}.tmp.svg") as file:
|
class Tag:
|
||||||
svgdata = re.sub(
|
contents = None
|
||||||
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
|
attribs: Attribs = field(default_factory=Attribs)
|
||||||
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
|
flat: bool = None
|
||||||
file.read(),
|
delete_if_empty: bool = False
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# generate BOM table
|
def __init__(self, contents, flat=None, delete_if_empty=False, **kwargs):
|
||||||
bom = flatten2d(bom_list)
|
self.contents = contents
|
||||||
|
self.flat = flat
|
||||||
|
self.delete_if_empty = delete_if_empty
|
||||||
|
self.attribs = Attribs({**kwargs})
|
||||||
|
|
||||||
# generate BOM header (may be at the top or bottom of the table)
|
def update_attribs(self, **kwargs):
|
||||||
bom_header_html = " <tr>\n"
|
for k, v in kwargs.items():
|
||||||
for item in bom[0]:
|
self.attribs[k] = v
|
||||||
th_class = f"bom_col_{item.lower()}"
|
|
||||||
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
|
|
||||||
bom_header_html = f"{bom_header_html} </tr>\n"
|
|
||||||
|
|
||||||
# generate BOM contents
|
@property
|
||||||
bom_contents = []
|
def tagname(self):
|
||||||
for row in bom[1:]:
|
return type(self).__name__.lower()
|
||||||
row_html = " <tr>\n"
|
|
||||||
for i, item in enumerate(row):
|
|
||||||
td_class = f"bom_col_{bom[0][i].lower()}"
|
|
||||||
row_html = f'{row_html} <td class="{td_class}">{item}</td>\n'
|
|
||||||
row_html = f"{row_html} </tr>\n"
|
|
||||||
bom_contents.append(row_html)
|
|
||||||
|
|
||||||
bom_html = (
|
@property
|
||||||
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
|
def auto_flat(self):
|
||||||
)
|
if self.flat is not None: # user specified
|
||||||
bom_html_reversed = (
|
return self.flat
|
||||||
'<table class="bom">\n'
|
if not _is_iterable_not_str(self.contents): # catch str, int, float, ...
|
||||||
+ "".join(list(reversed(bom_contents)))
|
if not isinstance(self.contents, Tag): # avoid recursion
|
||||||
+ bom_header_html
|
return not "\n" in str(self.contents) # flatten if single line
|
||||||
+ "</table>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# prepare simple replacements
|
@property
|
||||||
replacements = {
|
def is_empty(self):
|
||||||
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
|
return self.get_contents(force_flat=True) == ""
|
||||||
"<!-- %fontname% -->": options.fontname,
|
|
||||||
"<!-- %bgcolor% -->": options.bgcolor.html,
|
|
||||||
"<!-- %diagram% -->": svgdata,
|
|
||||||
"<!-- %bom% -->": bom_html,
|
|
||||||
"<!-- %bom_reversed% -->": bom_html_reversed,
|
|
||||||
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
|
|
||||||
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
|
|
||||||
}
|
|
||||||
|
|
||||||
# prepare metadata replacements
|
def indent_lines(self, lines, force_flat=False):
|
||||||
if metadata:
|
if self.auto_flat or force_flat:
|
||||||
for item, contents in metadata.items():
|
return lines
|
||||||
if isinstance(contents, (str, int, float)):
|
else:
|
||||||
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
|
indenter = " " * indent_count
|
||||||
elif isinstance(contents, Dict): # useful for authors, revisions
|
return "\n".join(f"{indenter}{line}" for line in lines.split("\n"))
|
||||||
for index, (category, entry) in enumerate(contents.items()):
|
|
||||||
if isinstance(entry, Dict):
|
|
||||||
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
|
|
||||||
for entry_key, entry_value in entry.items():
|
|
||||||
replacements[
|
|
||||||
f"<!-- %{item}_{index+1}_{entry_key}% -->"
|
|
||||||
] = html_line_breaks(str(entry_value))
|
|
||||||
|
|
||||||
replacements['"sheetsize_default"'] = '"{}"'.format(
|
def get_contents(self, force_flat=False):
|
||||||
metadata.get("template", {}).get("sheetsize", "")
|
separator = "" if self.auto_flat or force_flat else "\n"
|
||||||
)
|
if _is_iterable_not_str(self.contents):
|
||||||
# include quotes so no replacement happens within <style> definition
|
return separator.join(
|
||||||
|
[
|
||||||
|
self.indent_lines(str(c), force_flat)
|
||||||
|
for c in self.contents
|
||||||
|
if c is not None
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif self.contents is None:
|
||||||
|
return ""
|
||||||
|
else: # str, int, float, etc.
|
||||||
|
return self.indent_lines(str(self.contents), force_flat)
|
||||||
|
|
||||||
# perform replacements
|
def __repr__(self):
|
||||||
# regex replacement adapted from:
|
separator = "" if self.auto_flat else "\n"
|
||||||
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
|
if self.delete_if_empty and self.is_empty:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
html = [
|
||||||
|
f"<{self.tagname}{str(self.attribs)}>",
|
||||||
|
f"{self.get_contents()}",
|
||||||
|
f"</{self.tagname}>",
|
||||||
|
]
|
||||||
|
html_joined = separator.join(html)
|
||||||
|
return html_joined
|
||||||
|
|
||||||
# longer replacements first, just in case
|
|
||||||
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)
|
|
||||||
|
|
||||||
open_file_write(f"{filename}.html").write(html)
|
@dataclass
|
||||||
|
class TagSingleton(Tag):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.attribs = Attribs({**kwargs})
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.tagname}{str(self.attribs)} />"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iterable_not_str(inp):
|
||||||
|
# str is iterable, but should be treated as not iterable
|
||||||
|
return isinstance(inp, Iterable) and not isinstance(inp, str)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Br(TagSingleton):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Img(TagSingleton):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Td(Tag):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Tr(Tag):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Table(Tag):
|
||||||
|
pass
|
||||||
|
|||||||
168
src/wireviz/wv_output.py
Normal file
168
src/wireviz/wv_output.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
|
import wireviz # for doing wireviz.__file__
|
||||||
|
from wireviz import APP_NAME, APP_URL, __version__
|
||||||
|
from wireviz.wv_dataclasses import Metadata, Options
|
||||||
|
from wireviz.wv_utils import (
|
||||||
|
flatten2d,
|
||||||
|
html_line_breaks,
|
||||||
|
open_file_read,
|
||||||
|
open_file_write,
|
||||||
|
smart_file_resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
mime_subtype_replacements = {"jpg": "jpeg", "tif": "tiff"}
|
||||||
|
|
||||||
|
|
||||||
|
def embed_svg_images(svg_in: str, base_path: Union[str, Path] = Path.cwd()) -> str:
|
||||||
|
images_b64 = {} # cache of base64-encoded images
|
||||||
|
|
||||||
|
def image_tag(pre: str, url: str, post: str) -> str:
|
||||||
|
return f'<image{pre} xlink:href="{url}"{post}>'
|
||||||
|
|
||||||
|
def replace(match: re.Match) -> str:
|
||||||
|
imgurl = match["URL"]
|
||||||
|
if not imgurl in images_b64: # only encode/cache every unique URL once
|
||||||
|
imgurl_abs = (Path(base_path) / imgurl).resolve()
|
||||||
|
image = imgurl_abs.read_bytes()
|
||||||
|
images_b64[imgurl] = base64.b64encode(image).decode("utf-8")
|
||||||
|
return image_tag(
|
||||||
|
match["PRE"] or "",
|
||||||
|
f"data:image/{get_mime_subtype(imgurl)};base64, {images_b64[imgurl]}",
|
||||||
|
match["POST"] or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
pattern = re.compile(
|
||||||
|
image_tag(r"(?P<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return pattern.sub(replace, svg_in)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mime_subtype(filename: Union[str, Path]) -> str:
|
||||||
|
mime_subtype = Path(filename).suffix.lstrip(".").lower()
|
||||||
|
if mime_subtype in mime_subtype_replacements:
|
||||||
|
mime_subtype = mime_subtype_replacements[mime_subtype]
|
||||||
|
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(
|
||||||
|
embed_svg_images(filename_in.read_text(), filename_in.parent)
|
||||||
|
)
|
||||||
|
if overwrite:
|
||||||
|
filename_out.replace(filename_in)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html_output(
|
||||||
|
filename: Union[str, Path],
|
||||||
|
bom_list: List[List[str]],
|
||||||
|
metadata: Metadata,
|
||||||
|
options: Options,
|
||||||
|
):
|
||||||
|
|
||||||
|
# load HTML template
|
||||||
|
templatename = metadata.get("template", {}).get("name")
|
||||||
|
if templatename:
|
||||||
|
# if relative path to template was provided,
|
||||||
|
# check directory of YAML file first, fall back to built-in template directory
|
||||||
|
templatefile = smart_file_resolve(
|
||||||
|
f"{templatename}.html",
|
||||||
|
[Path(filename).parent, Path(__file__).parent / "templates"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# fall back to built-in simple template if no template was provided
|
||||||
|
templatefile = Path(wireviz.__file__).parent / "templates/simple.html"
|
||||||
|
|
||||||
|
html = open_file_read(templatefile).read()
|
||||||
|
|
||||||
|
# embed SVG diagram
|
||||||
|
with open_file_read(f"{filename}.tmp.svg") as file:
|
||||||
|
svgdata = re.sub(
|
||||||
|
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
|
||||||
|
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
|
||||||
|
file.read(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# generate BOM table
|
||||||
|
bom = flatten2d(bom_list)
|
||||||
|
|
||||||
|
# generate BOM header (may be at the top or bottom of the table)
|
||||||
|
bom_header_html = " <tr>\n"
|
||||||
|
for item in bom[0]:
|
||||||
|
th_class = f"bom_col_{item.lower()}"
|
||||||
|
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
|
||||||
|
bom_header_html = f"{bom_header_html} </tr>\n"
|
||||||
|
|
||||||
|
# generate BOM contents
|
||||||
|
bom_contents = []
|
||||||
|
for row in bom[1:]:
|
||||||
|
row_html = " <tr>\n"
|
||||||
|
for i, item in enumerate(row):
|
||||||
|
td_class = f"bom_col_{bom[0][i].lower()}"
|
||||||
|
row_html = f'{row_html} <td class="{td_class}">{item}</td>\n'
|
||||||
|
row_html = f"{row_html} </tr>\n"
|
||||||
|
bom_contents.append(row_html)
|
||||||
|
|
||||||
|
bom_html = (
|
||||||
|
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
|
||||||
|
)
|
||||||
|
bom_html_reversed = (
|
||||||
|
'<table class="bom">\n'
|
||||||
|
+ "".join(list(reversed(bom_contents)))
|
||||||
|
+ bom_header_html
|
||||||
|
+ "</table>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# prepare simple replacements
|
||||||
|
replacements = {
|
||||||
|
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
|
||||||
|
"<!-- %fontname% -->": options.fontname,
|
||||||
|
"<!-- %bgcolor% -->": options.bgcolor.html,
|
||||||
|
"<!-- %diagram% -->": svgdata,
|
||||||
|
"<!-- %bom% -->": bom_html,
|
||||||
|
"<!-- %bom_reversed% -->": bom_html_reversed,
|
||||||
|
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
|
||||||
|
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare metadata replacements
|
||||||
|
if metadata:
|
||||||
|
for item, contents in metadata.items():
|
||||||
|
if isinstance(contents, (str, int, float)):
|
||||||
|
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
|
||||||
|
elif isinstance(contents, Dict): # useful for authors, revisions
|
||||||
|
for index, (category, entry) in enumerate(contents.items()):
|
||||||
|
if isinstance(entry, Dict):
|
||||||
|
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
|
||||||
|
for entry_key, entry_value in entry.items():
|
||||||
|
replacements[
|
||||||
|
f"<!-- %{item}_{index+1}_{entry_key}% -->"
|
||||||
|
] = html_line_breaks(str(entry_value))
|
||||||
|
|
||||||
|
replacements['"sheetsize_default"'] = '"{}"'.format(
|
||||||
|
metadata.get("template", {}).get("sheetsize", "")
|
||||||
|
)
|
||||||
|
# include quotes so no replacement happens within <style> definition
|
||||||
|
|
||||||
|
# perform replacements
|
||||||
|
# regex replacement adapted from:
|
||||||
|
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
|
||||||
|
|
||||||
|
# longer replacements first, just in case
|
||||||
|
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)
|
||||||
|
|
||||||
|
open_file_write(f"{filename}.html").write(html)
|
||||||
@ -1,125 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
indent_count = 1
|
|
||||||
|
|
||||||
|
|
||||||
class Attribs(Dict):
|
|
||||||
def __repr__(self):
|
|
||||||
if len(self) == 0:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
html = []
|
|
||||||
for k, v in self.items():
|
|
||||||
if v is not None:
|
|
||||||
html.append(f' {k}="{v}"')
|
|
||||||
# else:
|
|
||||||
# html.append(f" {k}")
|
|
||||||
return "".join(html)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Tag:
|
|
||||||
contents = None
|
|
||||||
attribs: Attribs = field(default_factory=Attribs)
|
|
||||||
flat: bool = None
|
|
||||||
delete_if_empty: bool = False
|
|
||||||
|
|
||||||
def __init__(self, contents, flat=None, delete_if_empty=False, **kwargs):
|
|
||||||
self.contents = contents
|
|
||||||
self.flat = flat
|
|
||||||
self.delete_if_empty = delete_if_empty
|
|
||||||
self.attribs = Attribs({**kwargs})
|
|
||||||
|
|
||||||
def update_attribs(self, **kwargs):
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
self.attribs[k] = v
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tagname(self):
|
|
||||||
return type(self).__name__.lower()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auto_flat(self):
|
|
||||||
if self.flat is not None: # user specified
|
|
||||||
return self.flat
|
|
||||||
if not _is_iterable_not_str(self.contents): # catch str, int, float, ...
|
|
||||||
if not isinstance(self.contents, Tag): # avoid recursion
|
|
||||||
return not "\n" in str(self.contents) # flatten if single line
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_empty(self):
|
|
||||||
return self.get_contents(force_flat=True) == ""
|
|
||||||
|
|
||||||
def indent_lines(self, lines, force_flat=False):
|
|
||||||
if self.auto_flat or force_flat:
|
|
||||||
return lines
|
|
||||||
else:
|
|
||||||
indenter = " " * indent_count
|
|
||||||
return "\n".join(f"{indenter}{line}" for line in lines.split("\n"))
|
|
||||||
|
|
||||||
def get_contents(self, force_flat=False):
|
|
||||||
separator = "" if self.auto_flat or force_flat else "\n"
|
|
||||||
if _is_iterable_not_str(self.contents):
|
|
||||||
return separator.join(
|
|
||||||
[
|
|
||||||
self.indent_lines(str(c), force_flat)
|
|
||||||
for c in self.contents
|
|
||||||
if c is not None
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif self.contents is None:
|
|
||||||
return ""
|
|
||||||
else: # str, int, float, etc.
|
|
||||||
return self.indent_lines(str(self.contents), force_flat)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
separator = "" if self.auto_flat else "\n"
|
|
||||||
if self.delete_if_empty and self.is_empty:
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
html = [
|
|
||||||
f"<{self.tagname}{str(self.attribs)}>",
|
|
||||||
f"{self.get_contents()}",
|
|
||||||
f"</{self.tagname}>",
|
|
||||||
]
|
|
||||||
html_joined = separator.join(html)
|
|
||||||
return html_joined
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TagSingleton(Tag):
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.attribs = Attribs({**kwargs})
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<{self.tagname}{str(self.attribs)} />"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_iterable_not_str(inp):
|
|
||||||
# str is iterable, but should be treated as not iterable
|
|
||||||
return isinstance(inp, Iterable) and not isinstance(inp, str)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Br(TagSingleton):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Img(TagSingleton):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Td(Tag):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Tr(Tag):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Table(Tag):
|
|
||||||
pass
|
|
||||||
Loading…
x
Reference in New Issue
Block a user