Skip to content

Commit a7ea500

Browse files
eduralphGaryGriffin
authored andcommitted
Quote handles in GraphView Graphviz output
The Graph View wrote each Gramps handle into the Graphviz/DOT graph as an unquoted node id (add_node, add_link and the subgraph cluster name). Handles may be any string up to 50 characters, and Gramps-Web creates UUIDv4 handles containing hyphens. Graphviz splits an unquoted id at the hyphen, so the node, edge and cluster names were mangled and the view rendered blank. Quote the handle at the three sites where it is emitted as a DOT id, so a hyphenated handle is treated as a single id. A quoted name beginning with "cluster" is still treated as a cluster by Graphviz, so grouping is unchanged. The core Graphviz reports already quote their node ids. Fixes #13832.
1 parent 8b9a30c commit a7ea500

3 files changed

Lines changed: 115 additions & 4 deletions

File tree

GraphView/graphview.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3321,9 +3321,12 @@ def add_link(self, id1, id2, style="", head="", tail="", comment="",
33213321
Add a link between two nodes.
33223322
Gramps handles are used as nodes but need to be prefixed
33233323
with an underscore because Graphviz does not like IDs
3324-
that begin with a number.
3324+
that begin with a number. The id is quoted because handles are
3325+
arbitrary schema-valid strings: Gramps-Web creates UUIDv4 handles
3326+
containing hyphens, which an unquoted Graphviz ID would split on
3327+
(bug 13832).
33253328
"""
3326-
self.write(' _%s -> _%s' % (id1, id2))
3329+
self.write(' "_%s" -> "_%s"' % (id1, id2))
33273330

33283331
boldok = False
33293332
if id1 in self.current_list:
@@ -3390,7 +3393,10 @@ def add_node(self, node_id, label, shape="", color="",
33903393
text += ' URL="%s"' % url
33913394

33923395
text += " ]"
3393-
self.write(' _%s %s;\n' % (node_id, text))
3396+
# Quote the id: handles are arbitrary schema-valid strings, and
3397+
# Gramps-Web creates UUIDv4 handles with hyphens that an unquoted
3398+
# Graphviz ID would split on, blanking the graph (bug 13832).
3399+
self.write(' "_%s" %s;\n' % (node_id, text))
33943400

33953401
def add_tags_tooltip(self, handle, tag_list):
33963402
"""
@@ -3407,7 +3413,11 @@ def start_subgraph(self, graph_id):
34073413
Opens a subgraph which is used to keep together related nodes
34083414
on the graph.
34093415
"""
3410-
self.write('\n subgraph cluster_%s\n' % graph_id)
3416+
# Quote the name: graph_id is a handle (see bug 13832); a UUIDv4
3417+
# handle with hyphens would break an unquoted subgraph name.
3418+
# Graphviz still treats a quoted name beginning with "cluster" as
3419+
# a cluster subgraph.
3420+
self.write('\n subgraph "cluster_%s"\n' % graph_id)
34113421
self.write(' {\n')
34123422
# no border around subgraph (#0002176)
34133423
self.write(' style="invis";\n')

GraphView/tests/__init__.py

Whitespace-only changes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Eduard Ralph
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along
17+
# with this program; if not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
"""
21+
Unit tests for Graphviz node-id quoting in the GraphView addon (bug 13832).
22+
"""
23+
24+
import unittest
25+
from io import StringIO
26+
27+
# GraphView imports Gtk/GooCanvas at module load; on a host without those
28+
# typelibs (or a display) the import raises. Skip cleanly there rather than
29+
# erroring, so the suite stays runnable without the addon's requires_gi deps.
30+
try:
31+
import gi
32+
33+
gi.require_version("Gtk", "3.0")
34+
from GraphView.graphview import DotSvgGenerator
35+
36+
_IMPORT_ERROR = None
37+
except Exception as err: # pragma: no cover - depends on host gi stack
38+
DotSvgGenerator = None
39+
_IMPORT_ERROR = err
40+
41+
42+
# A Gramps-Web UUIDv4 handle (hyphens) vs. a desktop handle (no hyphens).
43+
HYPHEN_HANDLE = "22e6b2a0-269e-4c58-8e27-0c38b2ef5a10"
44+
PLAIN_HANDLE = "fe4861b093015ddcbb08044be02"
45+
46+
47+
# ------------------------------------------------------------
48+
#
49+
# DotHandleQuotingTest
50+
#
51+
# ------------------------------------------------------------
52+
@unittest.skipIf(
53+
DotSvgGenerator is None,
54+
"GraphView import unavailable (gi/GooCanvas/display): %s" % (_IMPORT_ERROR,),
55+
)
56+
class DotHandleQuotingTest(unittest.TestCase):
57+
"""
58+
The DOT generator emits Gramps handles as node ids. Handles are
59+
arbitrary schema-valid strings (<=50 chars); Gramps-Web creates
60+
UUIDv4 handles containing hyphens. An unquoted Graphviz id is split at
61+
the first hyphen, so the node/edge/cluster name is mangled and the
62+
Graph View blanks (bug 13832). Every handle written into the DOT must
63+
therefore be quoted.
64+
"""
65+
66+
def _generator(self):
67+
# Bypass __init__ (which needs a live dbstate/view); the DOT-writing
68+
# methods only touch the attributes set up here.
69+
gen = DotSvgGenerator.__new__(DotSvgGenerator)
70+
gen.dot = StringIO()
71+
gen.current_list = set()
72+
gen.colors = {"link_color": "#000000"}
73+
return gen
74+
75+
def test_add_node_quotes_hyphenated_handle(self):
76+
gen = self._generator()
77+
gen.add_node(HYPHEN_HANDLE, "Some Label")
78+
out = gen.dot.getvalue()
79+
self.assertIn('"_%s"' % HYPHEN_HANDLE, out)
80+
81+
def test_add_node_quotes_plain_handle(self):
82+
gen = self._generator()
83+
gen.add_node(PLAIN_HANDLE, "Some Label")
84+
out = gen.dot.getvalue()
85+
self.assertIn('"_%s"' % PLAIN_HANDLE, out)
86+
87+
def test_add_link_quotes_both_endpoints(self):
88+
gen = self._generator()
89+
gen.add_link(HYPHEN_HANDLE, PLAIN_HANDLE)
90+
out = gen.dot.getvalue()
91+
self.assertIn('"_%s" -> "_%s"' % (HYPHEN_HANDLE, PLAIN_HANDLE), out)
92+
93+
def test_start_subgraph_quotes_cluster_name(self):
94+
gen = self._generator()
95+
gen.start_subgraph(HYPHEN_HANDLE)
96+
out = gen.dot.getvalue()
97+
self.assertIn('"cluster_%s"' % HYPHEN_HANDLE, out)
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

0 commit comments

Comments
 (0)