diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 59c0e52e..8d600c4f 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -322,6 +322,44 @@ LEGEND.forEach(c => {{
}});
"""
+
+def _html_document_title(output_path: str) -> str:
+ """Return a portable label for the graph.html
.
+
+ Tracked artifacts must not embed the generator host absolute path
+ (regression of #433; reported again as #2598 on Windows). Prefer a
+ path relative to the process cwd; otherwise keep from the configured
+ output-dir bare name (``graphify-out`` / ``GRAPHIFY_OUT`` basename)
+ onward; finally fall back to the filename only.
+ """
+ from graphify.paths import GRAPHIFY_OUT_NAME
+
+ raw = str(output_path).replace("\\", "/")
+ # Drop Windows drive prefix so Path parts are comparable on any OS.
+ if len(raw) >= 3 and raw[1] == ":" and raw[0].isalpha() and raw[2] == "/":
+ raw = raw[2:] # "/Users/..." style after drive strip
+ p = Path(raw)
+
+ try:
+ resolved = p if p.is_absolute() else (Path.cwd() / p)
+ rel = resolved.resolve().relative_to(Path.cwd().resolve())
+ label = rel.as_posix()
+ if label and label != ".":
+ return label
+ except (ValueError, OSError, RuntimeError):
+ pass
+
+ parts = list(Path(raw).parts)
+ # Path("C:/Users/..") on POSIX may keep "C:" as first part — strip it.
+ if parts and len(parts[0]) == 2 and parts[0][1] == ":" and parts[0][0].isalpha():
+ parts = parts[1:]
+ marker = GRAPHIFY_OUT_NAME
+ for i, part in enumerate(parts):
+ if part == marker or part.startswith("graphify-out"):
+ return "/".join(parts[i:])
+ name = p.name
+ return name if name else "graph.html"
+
def to_html(
G: nx.Graph,
communities: dict[int, list[str]],
@@ -519,7 +557,7 @@ def to_html(
edges_json = _js_safe(vis_edges)
legend_json = _js_safe(legend_data)
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
- title = _html.escape(sanitize_label(str(output_path)))
+ title = _html.escape(sanitize_label(_html_document_title(output_path)))
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
html = f"""
diff --git a/tests/test_export.py b/tests/test_export.py
index 86c5c955..657856c1 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -191,6 +191,37 @@ def test_to_html_contains_visjs():
assert "vis-network" in content
+
+def test_to_html_title_uses_portable_path_not_host_absolute():
+ """#2598 / #433: must not embed the generator host absolute path."""
+ import re
+
+ G = make_graph()
+ communities = cluster(G)
+ with tempfile.TemporaryDirectory() as tmp:
+ userish = Path(tmp) / "Users" / "mike" / "proj" / "graphify-out" / "graph.html"
+ userish.parent.mkdir(parents=True)
+ to_html(G, communities, str(userish))
+ html = userish.read_text(encoding="utf-8")
+ m = re.search(r"(.*?)", html)
+ assert m, "expected a tag"
+ title = m.group(1)
+ assert title.startswith("graphify - ")
+ label = title[len("graphify - "):]
+ assert "mike" not in label
+ assert "Users" not in label
+ assert not label.startswith("/")
+ assert "graphify-out/graph.html" in label or label == "graph.html"
+
+
+def test_html_document_title_helper_windows_and_relative():
+ from graphify.exporters.html import _html_document_title
+
+ assert _html_document_title(r"C:\Users\mike\proj\graphify-out\graph.html") == "graphify-out/graph.html"
+ assert _html_document_title("/home/u/proj/graphify-out/graph.html") == "graphify-out/graph.html"
+ assert _html_document_title("graphify-out/graph.html") == "graphify-out/graph.html"
+ assert _html_document_title("/tmp/only/graph.html") == "graph.html"
+
def test_to_html_neighbor_links_have_no_inline_onclick_xss():
"""#1838: neighbor links dropped an unescaped JSON.stringify(nid) into a
quoted inline onclick — which broke every link (the value's own quotes