Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ CI (`.github/workflows/`) runs both `pytest` (the `Tests` workflow) and `build_e
- **`tests/test_bom.py`** — BOM aggregation: identical-component dedup, ignore_in_bom, additional_bom_items, bundle category, part-number columns.
- **`tests/test_regressions.py`** — **one test per upstream-PR port + every gemini review fix.** This is where every bug we ported a fix for gets pinned down so it can never silently regress. If you change behavior touched by any of those PRs, expect tests here to fail and update them deliberately.
- **`tests/test_round_trip.py`** — PNG embed/extract, stdin→stdout pipelines, dict-input no-mutation contract.
- **`tests/test_upstream_issues.py`** — one test per upstream wireviz/WireViz issue fixed in the fork (`test_issueNNN_*`). Triage of all upstream issues: `docs/plans/2026-10-02-upstream-issue-triage.md`.
- **`tests/test_security.py`** — one test per finding of the October 2026 security audit (C1, C2, H1-H3, M1-M3, L1): always-on limits plus every `untrusted=True` rule. The `test_audit_*` tests in `test_regressions.py` pin the bugs from the same audit.

`tests/conftest.py` provides shared fixtures (paths to small targeted YAMLs in `tests/fixtures/`). The fixture YAMLs are deliberately separate from the gallery YAMLs in `examples/` so tests aren't coupled to visual gallery changes.
Expand Down Expand Up @@ -97,6 +98,7 @@ The pipeline is **YAML → Harness object graph → GraphViz `.gv` → rendered
- **`svgembed.py`** — inlines referenced raster images into the SVG so the SVG/HTML output is self-contained.
- **`wv_bom.py`** — BOM aggregation/dedup logic (`mini_bom_mode`, part-number handling, additional components). Reused for both the standalone `.bom.tsv` and the BOM table embedded in HTML.
- **`wv_colors.py`** — IEC 60757 color codes, color-scheme generators (DIN 47100, 25-pair, TIA/EIA 568), `ColorMode` (SHORT / FULL / HEX, upper/lower).
- **`wv_images.py`** — turns `data:image/...;base64` URIs and `.webp` files into PNG files in the harness's private temp dir (`Harness.temp_dir()`, removed when the Harness is garbage-collected). Graphviz needs a file in a format its build can read.
- **`wv_helper.py`** — shared utilities: `awg_equiv`/`mm2_equiv` gauge conversion, `expand` (range syntax), `tuplelist2tsv`, `smart_file_resolve` (image-path resolution against the input dir + `--prepend` dirs).

### Cross-cutting things to know
Expand All @@ -107,6 +109,12 @@ The pipeline is **YAML → Harness object graph → GraphViz `.gv` → rendered
- **Backwards-compat shims**: `OLD_CONNECTOR_ATTR` in `Harness.py` maps deprecated keys (`pinout`, `pinnumbers`, `autogenerate`) to friendly errors. Add new deprecations there rather than silently accepting old keys.
- **Generated artifacts (`*.gv`, `*.svg`, `*.png`, `*.html`, `*.bom.tsv` under `examples/`, `tutorial/`) are checked into git.** Don't include incidental rebuilds in PRs — the project policy (per `CONTRIBUTING.md`) is that maintainers rebuild on merge. Use `build_examples.py restore` before committing.

## YAML loading and text escaping (load-bearing)

- **Load YAML only through `wv_helper.yaml_load()`** — never `yaml.safe_load`. It uses YAML 1.2 booleans, so `NO`/`NC`/`ON`/`Yes` stay text in pin and wire labels (upstream #305). Boolean dataclass fields convert yes/no/on/off back through `DataClasses._coerce_bools`; a new `bool` field gets this automatically, a new boolean outside a dataclass needs `wv_helper.yaml11_bool`.
- **Text that goes into a Graphviz HTML label goes through `wv_gv_html.html_text()` or `html_line_breaks()`**, which escape bare `&`, `<` and `>` while keeping tags and entities (upstream #230). The DOT parser counts angle brackets, so one bare `>` breaks the whole graph.
- **Node names and edges:** `dot.node(nohtml(name))` and `Harness._edge()` — never `dot.edge("a:p1r:e", ...)`. graphviz splits edge strings on `:` (upstream #487) and treats a name like `<X>` as an HTML label.

## Untrusted input (load-bearing)

`wireviz-gui` renders YAML sent from a browser, so this library must be safe for input its caller did not write. Design: `docs/plans/2026-10-02-october-2026-audit.md`. The contract:
Expand All @@ -116,6 +124,7 @@ The pipeline is **YAML → Harness object graph → GraphViz `.gv` → rendered
- **`parse(..., untrusted=True)`** sets `Harness.untrusted`, and `Harness._render` honors it (the sidecar calls `_render` directly). Untrusted: a `str` input is always YAML text (never a path); 1 MB cap; `image.src` relative and inside `image_paths`, ≤ 50 MP; `metadata.template.name` a bare name; `tweak` refused; SVG rebuilt by `sanitize_svg`; HTML values through `sanitize_html_fragment`; Graphviz via subprocess with a timeout (`Harness._pipe`).
- **Graphviz loads every `<img>` in an HTML-like label**, including one a user types into `notes`, and rasterizes it into PNG/PDF. In untrusted mode `wv_safety.check_dot_images` (called from `Harness._pipe`) allows only the exact strings `wv_gv_html.html_img_tag` generates and refuses any other `<img`. Do not replace this with attribute parsing: Graphviz reads attribute names case-insensitively and uses the last `src`. If you change the generated `<img>` markup, change it only in `html_img_tag`.
- In untrusted HTML output a metadata key may not replace a built-in placeholder (`fontname`, `bgcolor`, ...), and `metadata.template.sheetsize` must be a bare name: the templates put those values in attribute and style contexts.
- `image.src` may be a `data:image/...;base64` URI (`wv_images.py`). It is decoded to a PNG in `Harness.temp_dir()`, with the same pixel cap; in untrusted mode it is the only image source that needs no `image_paths`. Never let a data URI name a file path.
- **Every value in an HTML-like label is escaped (`html_text`) or typed.** The DOT parser ends an HTML label where `<` and `>` balance, so a raw `>` turns the rest of the label into DOT (which can load files). In untrusted mode `wv_safety.check_html_label` refuses any generated node label that is not well-formed or has unbalanced brackets — a backstop, not a replacement for escaping.
- New output paths that put user text into SVG or HTML must go through the same sanitizers when `untrusted` is set, and get a test in `tests/test_security.py`.
- `parse()` never mutates its arguments: `image_paths` is copied, dict input is deep-copied, each connection set is deep-copied before expansion.
Expand Down
13 changes: 13 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ Fixes for open issues in the original [wireviz/WireViz](https://github.com/wirev
- `image: file.png` works as a short form of `image: {src: file.png}` ([#292](https://github.com/wireviz/WireViz/issues/292)).
- Loops accept pin labels ([#432](https://github.com/wireviz/WireViz/issues/432)); loops on non-sequential pin numbers have a regression test ([#465](https://github.com/wireviz/WireViz/issues/465)).

### New features

- CSV BOM output: `-f c` / `output_formats="csv"` writes `<name>.bom.csv` ([#98](https://github.com/wireviz/WireViz/issues/98)).
- Loop colors: `loops: [{RD: [VCC, SENSE]}]` ([#457](https://github.com/wireviz/WireViz/issues/457)).
- `options.show_title: true` draws `metadata.title` above the diagram in PNG, SVG and PDF ([#460](https://github.com/wireviz/WireViz/issues/460)).
- `--disable-key KEY` (CLI) and `parse(disable_keys=...)` drop an attribute such as `image` from all components, or `X1.image` from one ([#410](https://github.com/wireviz/WireViz/issues/410)).
- Cable `show_box: false` hides the cable box and draws each wire straight from connector to connector ([#212](https://github.com/wireviz/WireViz/issues/212), [#453](https://github.com/wireviz/WireViz/issues/453)).
- CSS/HTML color names such as `lightgreen` or `tomato` ([#135](https://github.com/wireviz/WireViz/issues/135), [#271](https://github.com/wireviz/WireViz/issues/271)).
- `.webp` images are converted to PNG before rendering ([#202](https://github.com/wireviz/WireViz/issues/202)).
- Embedded images: `image: data:image/png;base64,...` ([#188](https://github.com/wireviz/WireViz/issues/188), [#322](https://github.com/wireviz/WireViz/issues/322)). This also works in untrusted mode, which allows no file paths.
- `options.terminology` replaces "pin", "wire" and "shield" in the diagram and BOM ([#331](https://github.com/wireviz/WireViz/issues/331)).
- Connector `strip: {sleeve: 10, insulation: 2.5}` shows stripping lengths in the diagram ([#296](https://github.com/wireviz/WireViz/issues/296)).

## [1.0.0] (2026-10-02)

Security hardening and bug fixes from the October 2026 audit. First stable release of the fork. Design and threat model: [docs/plans/2026-10-02-october-2026-audit.md](plans/2026-10-02-october-2026-audit.md).
Expand Down
27 changes: 26 additions & 1 deletion docs/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ tweak: # optional tweaking of .gv output
# loops
loops: <List> # every list item is itself a list of exactly two pins
# on the connector that are to be shorted;
# pins may be given by number or by pin label, e.g. [VCC, SENSE]
# pins may be given by number or by pin label, e.g. [VCC, SENSE];
# give a loop a wire color with a one-key mapping: {RD: [VCC, SENSE]}

# stripping lengths at this connector (shown in the diagram)
strip:
sleeve: <int/float/str> # numbers are taken as mm, e.g. 10 or '0.4 in'
insulation: <int/float/str> # e.g. 2.5

# optional tweaking of .gv output executed for each instance of this connector
tweak: # see tweak section below
Expand Down Expand Up @@ -158,6 +164,9 @@ tweak: # optional tweaking of .gv output
show_name: <bool> # defaults to true
show_wirecount: <bool> # defaults to true
show_wirenumbers: <bool> # defaults to true for cables; false for bundles
show_box: <bool> # defaults to true; false hides the cable box and draws
# each wire straight from connector to connector
# (every wire then needs a connector at both ends)

# optional tweaking of .gv output executed for each instance of this cable
tweak: # see tweak section below
Expand Down Expand Up @@ -414,6 +423,16 @@ See [HTML Output Templates](../src/wireviz/templates/) for how metadata entries
# vector (SVG, PDF) output by dpi/72 when it is set. When omitted,
# PNG renders at 96 dpi and SVG/PDF at true size.
output_dpi: <float> # Default = not set

# Draw metadata.title above the diagram in PNG, SVG and PDF output.
show_title: <bool> # Default = false

# Replace the words "pin", "wire" and "shield" in the diagram and BOM,
# e.g. pin: way, wire: core, shield: screen. All entries are optional.
terminology:
pin: <str>
wire: <str>
shield: <str>
```


Expand Down Expand Up @@ -504,6 +523,8 @@ Alternatively items can be added to just the BOM by putting them in the section
Colors are defined via uppercase, two character strings.
Striped/banded wires can be specified by simply concatenating multiple colors, with no space inbetween, eg. `GNYE` for green-yellow.

A single color can also be given as a CSS/HTML color name such as `tomato` or `lightgreen` (case-insensitive), or as a hex value such as `#ff6347`.

The following colors are understood:

- `BK` ![##000000](https://via.placeholder.com/15/000000/000000?text=+) (black)
Expand Down Expand Up @@ -555,6 +576,10 @@ Supported color codes:

Both connectors and cables accept including an image with a caption within their respective nodes.

`src` is a file path, or an embedded image as a data URI (`data:image/png;base64,...`; png, jpeg, gif or webp, up to 10 MB). `.webp` images are converted to PNG before rendering, because many Graphviz builds cannot read them. Embedded and `.webp` images must really be in the declared format and have at most 50 million pixels. Their PNG copies live in a temporary directory that is removed after rendering, so a `.gv` file that refers to them cannot be rendered again later.

To render a file without its images, use `wireviz --disable-key image` (or `--disable-key X1.image` for one component). Any other connector or cable attribute works the same way. The component name is the key under `connectors:` or `cables:`; for an autogenerated instance such as `T.X1`, use the template name `T`.

```yaml
image: <path> # short form: only the path to the image file

Expand Down
66 changes: 65 additions & 1 deletion src/wireviz/DataClasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,41 @@ class Metadata(dict):
pass


@dataclass
class Terminology:
"""Words used in the diagram and BOM that a user may replace, for
example "way" or "contact" for "pin" (upstream #331, PR #398)."""

pin: Optional[PlainText] = None
wire: Optional[PlainText] = None
shield: Optional[PlainText] = None

def __post_init__(self):
self.pin = self.pin or "pin"
self.wire = self.wire or "wire"
self.shield = self.shield or "shield"


@dataclass
class Strip:
"""Stripping lengths at a connector (upstream #296, PR #446).
Numbers are taken as millimetres."""

sleeve: Union[float, str, None] = None
insulation: Union[float, str, None] = None

def description(self) -> Optional[str]:
parts = [
f"{name} {value if isinstance(value, str) else f'{value} mm'}"
for name, value in (
("sleeve", self.sleeve),
("insulation", self.insulation),
)
if value is not None
]
return "Strip " + ", ".join(parts) if parts else None


@dataclass
class Options:
fontname: PlainText = "arial"
Expand All @@ -94,10 +129,23 @@ class Options:
# Graphviz's 96 dpi and SVG/PDF at true size, identical to v0.4.1.
# (v0.5.0 defaulted to 96.0, which made SVG/PDF 1.33x too large.)
output_dpi: Optional[float] = None
# Draw metadata.title above the diagram in PNG/SVG/PDF output (#460).
show_title: bool = False
terminology: Optional[Terminology] = None

def __post_init__(self):
_coerce_bools(self)
check_fontname(self.fontname)
if not isinstance(self.terminology, Terminology):
terms = self.terminology or {}
if not isinstance(terms, dict) or set(terms) - {"pin", "wire", "shield"}:
raise ValueError(
"options.terminology must be a mapping with pin, wire "
f"and/or shield, not {terms!r}"
)
if not all(isinstance(v, str) or v is None for v in terms.values()):
raise ValueError("options.terminology values must be text")
self.terminology = Terminology(**terms)
if not self.bgcolor_node:
self.bgcolor_node = self.bgcolor
if not self.bgcolor_connector:
Expand Down Expand Up @@ -229,12 +277,15 @@ class Connector:
ignore_in_bom: bool = False
additional_components: List[AdditionalComponent] = field(default_factory=list)
tweak: Optional[Tweak] = None
strip: Optional[Strip] = None

def __post_init__(self) -> None:
_coerce_bools(self)
_check_lists(
f"Connector {self.name}", self, ("pins", "pinlabels", "pincolors", "loops")
)
if isinstance(self.strip, dict):
self.strip = Strip(**self.strip)
if isinstance(self.image, str): # `image: file.png` (upstream #292)
self.image = Image(src=self.image)
if isinstance(self.image, dict):
Expand Down Expand Up @@ -286,8 +337,18 @@ def __post_init__(self) -> None:
self.show_pincount = self.style != "simple"

resolved_loops = []
# Color of each loop, parallel to self.loops; None = default look.
self.loop_colors: List[Optional[Color]] = []
for loop in self.loops:
# TODO: include properties of wire used to create the loop
# A loop may carry a color: {RD: [1, 2]} (upstream #457, PR #288)
color = None
if isinstance(loop, dict) and len(loop) == 1:
color, loop = next(iter(loop.items()))
if not isinstance(color, str):
raise ValueError(
f"Connector {self.name}: a loop color must be a color "
f"name such as RD, not {color!r}"
)
if not isinstance(loop, (list, tuple)) or len(loop) != 2:
raise Exception(
f"Connector {self.name}: loops must be between exactly two pins"
Expand All @@ -298,6 +359,7 @@ def __post_init__(self) -> None:
# Make sure loop connected pins are not hidden.
self.activate_pin(pin, None)
resolved_loops.append(loop)
self.loop_colors.append(color)
self.loops = resolved_loops

for i, item in enumerate(self.additional_components):
Expand Down Expand Up @@ -426,6 +488,8 @@ class Cable:
color_code: Optional[ColorScheme] = None
show_name: Optional[bool] = None
show_wirecount: bool = True
# False: no cable box; each wire is drawn from connector to connector (#212)
show_box: bool = True
show_wirenumbers: Optional[bool] = None
ignore_in_bom: bool = False
additional_components: List[AdditionalComponent] = field(default_factory=list)
Expand Down
Loading
Loading