vsdxkit 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vsdx/__init__.py +66 -0
- vsdx/connectors.py +354 -0
- vsdx/containers.py +211 -0
- vsdx/formulae.py +89 -0
- vsdx/geometry.py +343 -0
- vsdx/logging_support.py +59 -0
- vsdx/masters.py +197 -0
- vsdx/media/media.vsdx +0 -0
- vsdx/media/palette_extended.vsdx +0 -0
- vsdx/media.py +78 -0
- vsdx/pages.py +592 -0
- vsdx/py.typed +0 -0
- vsdx/shapes.py +1129 -0
- vsdx/templating.py +208 -0
- vsdx/vsdxdiff.py +166 -0
- vsdx/vsdxfile.py +1472 -0
- vsdx/xmlio.py +206 -0
- vsdxkit-0.7.0.dist-info/METADATA +240 -0
- vsdxkit-0.7.0.dist-info/RECORD +22 -0
- vsdxkit-0.7.0.dist-info/WHEEL +5 -0
- vsdxkit-0.7.0.dist-info/licenses/LICENSE +30 -0
- vsdxkit-0.7.0.dist-info/top_level.txt +1 -0
vsdx/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""vsdxkit - create, edit and analyse Microsoft Visio .vsdx files.
|
|
2
|
+
|
|
3
|
+
The distribution installs as ``vsdxkit``; the import namespace stays ``vsdx``
|
|
4
|
+
so code written against the library this one descends from keeps working.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import xml.dom.minidom as minidom # minidom used for prettyprint
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from xml.etree.ElementTree import Element
|
|
10
|
+
|
|
11
|
+
namespace = "{http://schemas.microsoft.com/office/visio/2012/main}" # visio file name space
|
|
12
|
+
ext_prop_namespace = "{http://schemas.openxmlformats.org/officeDocument/2006/extended-properties}"
|
|
13
|
+
vt_namespace = "{http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes}"
|
|
14
|
+
r_namespace = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
15
|
+
document_rels_namespace = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
16
|
+
cont_types_namespace = "{http://schemas.openxmlformats.org/package/2006/content-types}"
|
|
17
|
+
|
|
18
|
+
# Ref: https://docs.microsoft.com/en-us/office/client-developer/visio/visio-file-format-reference
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pretty_print_element(xml: Element | ET.ElementTree) -> str:
|
|
22
|
+
if isinstance(xml, ET.ElementTree):
|
|
23
|
+
root = xml.getroot()
|
|
24
|
+
return minidom.parseString(ET.tostring(root) if root is not None else b"").toprettyxml()
|
|
25
|
+
return minidom.parseString(ET.tostring(xml)).toprettyxml()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__version__ = "0.7.0"
|
|
29
|
+
|
|
30
|
+
# Issue #250/#254 review: `Shape.connects` quotes `Connect` in its annotation,
|
|
31
|
+
# and `typing.get_type_hints` evaluates quoted names against the function's
|
|
32
|
+
# module __dict__ (module __getattr__ is not consulted). Inject the real class
|
|
33
|
+
# here, once both modules are fully initialised, so runtime introspection works.
|
|
34
|
+
from . import shapes as _shapes_module # noqa: E402
|
|
35
|
+
from .connectors import Connect # noqa: E402
|
|
36
|
+
from .containers import Container # noqa: E402
|
|
37
|
+
from .formulae import calc_value # noqa: E402
|
|
38
|
+
from .geometry import Geometry, GeometryCell, GeometryRow # noqa: E402
|
|
39
|
+
from .logging_support import attach_debug_stream_handler, get_logger # noqa: E402
|
|
40
|
+
from .media import Media # noqa: E402
|
|
41
|
+
from .pages import Page, PagePosition # noqa: E402
|
|
42
|
+
from .shapes import Cell, DataProperty, Shape # noqa: E402
|
|
43
|
+
from .vsdxfile import PackageLimitError, PackageLimits, VisioFile, VisioFileNotOpen # noqa: E402
|
|
44
|
+
|
|
45
|
+
_shapes_module.Connect = Connect
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Cell",
|
|
49
|
+
"Connect",
|
|
50
|
+
"Container",
|
|
51
|
+
"DataProperty",
|
|
52
|
+
"Geometry",
|
|
53
|
+
"GeometryCell",
|
|
54
|
+
"GeometryRow",
|
|
55
|
+
"Media",
|
|
56
|
+
"PackageLimitError",
|
|
57
|
+
"PackageLimits",
|
|
58
|
+
"Page",
|
|
59
|
+
"PagePosition",
|
|
60
|
+
"Shape",
|
|
61
|
+
"VisioFile",
|
|
62
|
+
"VisioFileNotOpen",
|
|
63
|
+
"attach_debug_stream_handler",
|
|
64
|
+
"calc_value",
|
|
65
|
+
"get_logger",
|
|
66
|
+
]
|
vsdx/connectors.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from xml.etree.ElementTree import Element
|
|
6
|
+
|
|
7
|
+
import vsdx
|
|
8
|
+
|
|
9
|
+
from .shapes import Shape
|
|
10
|
+
|
|
11
|
+
namespace = "{http://schemas.microsoft.com/office/visio/2012/main}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Connect:
|
|
15
|
+
"""Connect class to represent a connection between two `Shape` objects"""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _parse_route(route: str) -> tuple[bool, str | None]:
|
|
19
|
+
"""Return point-glue and routing choices after validating route tokens."""
|
|
20
|
+
route_parts: list[str] = route.split("|") if route else []
|
|
21
|
+
allowed_parts = {"dynamic", "point", "straight", "rightangle", "curved"}
|
|
22
|
+
unknown_parts = set(route_parts) - allowed_parts
|
|
23
|
+
if unknown_parts:
|
|
24
|
+
raise ValueError(f"unknown connector route part(s): {', '.join(sorted(unknown_parts))}")
|
|
25
|
+
routing_parts = [part for part in route_parts if part in {"straight", "rightangle", "curved"}]
|
|
26
|
+
if len(routing_parts) > 1:
|
|
27
|
+
raise ValueError("connector route may specify only one routing behaviour")
|
|
28
|
+
return "point" in route_parts, routing_parts[0] if routing_parts else None
|
|
29
|
+
|
|
30
|
+
def __init__(self, xml: Element | None = None, page: vsdx.Page | None = None):
|
|
31
|
+
if page is None:
|
|
32
|
+
raise ValueError("Connect requires the page containing the connection")
|
|
33
|
+
if xml is None:
|
|
34
|
+
raise ValueError("Connect requires the connection's XML element")
|
|
35
|
+
if type(xml) is not Element or xml.tag != f"{namespace}Connect":
|
|
36
|
+
raise ValueError(f"Connect requires a {namespace}Connect element, got {xml.tag!r}")
|
|
37
|
+
missing = [name for name in ("FromSheet", "ToSheet") if name not in xml.attrib]
|
|
38
|
+
if missing:
|
|
39
|
+
raise ValueError(f"Connect element is missing required attribute(s): {', '.join(missing)}")
|
|
40
|
+
self.xml = xml
|
|
41
|
+
self.page = page
|
|
42
|
+
self.from_id = xml.attrib["FromSheet"] # ref to the connector shape
|
|
43
|
+
self.to_id = xml.attrib["ToSheet"] # ref to the shape where the connector terminates
|
|
44
|
+
self.from_rel = xml.attrib.get("FromCell") # i.e. EndX / BeginX; optional per Connect_Type
|
|
45
|
+
self.to_rel = xml.attrib.get("ToCell") # i.e. PinX; optional per Connect_Type
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def create(
|
|
49
|
+
page: vsdx.Page | None = None,
|
|
50
|
+
from_shape: Shape | None = None,
|
|
51
|
+
to_shape: Shape | None = None,
|
|
52
|
+
route: str = "dynamic",
|
|
53
|
+
from_cp: int = 0,
|
|
54
|
+
to_cp: int = 0,
|
|
55
|
+
) -> Shape:
|
|
56
|
+
"""Create a new Connect object between from_shape and to_shape
|
|
57
|
+
|
|
58
|
+
route: 'dynamic' (shape glue, default), 'point' (connection-point glue),
|
|
59
|
+
optionally combined routing behaviour via 'straight', 'rightangle' or
|
|
60
|
+
'curved'. When route='point', from_cp/to_cp give the 0-based connection
|
|
61
|
+
point row index on the from/to shapes respectively.
|
|
62
|
+
|
|
63
|
+
:returns: a new Connect object
|
|
64
|
+
:rtype: Shape
|
|
65
|
+
"""
|
|
66
|
+
if page is None:
|
|
67
|
+
raise ValueError("Connect.create() requires a page")
|
|
68
|
+
if from_shape is None or to_shape is None:
|
|
69
|
+
raise ValueError("Connect.create() requires both from_shape and to_shape")
|
|
70
|
+
Connect._parse_route(route)
|
|
71
|
+
# validate everything _apply_glue can reject BEFORE provisioning
|
|
72
|
+
# masters, copying shapes or appending records (issue #9 atomicity)
|
|
73
|
+
Connect._validate_point_glue(from_shape, to_shape, route, from_cp, to_cp)
|
|
74
|
+
if (
|
|
75
|
+
from_shape is not None and to_shape is not None
|
|
76
|
+
): # create new connector shape and connect items between this and the two shapes
|
|
77
|
+
# create new connect shape and get id
|
|
78
|
+
media = page.vis._shared_media()
|
|
79
|
+
media_shape = media.straight_connector
|
|
80
|
+
# state-based guard: masters provisioned if the document already
|
|
81
|
+
# carries the masters relationship (the on-disk folder only exists
|
|
82
|
+
# after save, so an os.path.exists guard double-provisioned on
|
|
83
|
+
# 2nd+ calls)
|
|
84
|
+
masters_rel_present = any(
|
|
85
|
+
r.attrib.get("Type") == "http://schemas.microsoft.com/visio/2010/relationships/masters"
|
|
86
|
+
for r in page.vis.document_rels()
|
|
87
|
+
)
|
|
88
|
+
new_master_id = None
|
|
89
|
+
if not masters_rel_present:
|
|
90
|
+
# document has no masters at all: copy the media masters folder
|
|
91
|
+
for file_name, file in media.media.zip_file_contents.items():
|
|
92
|
+
if file_name.startswith(media.media._masters_folder):
|
|
93
|
+
new_file_name = file_name.replace(media.media._masters_folder, page.vis._masters_folder)
|
|
94
|
+
page.vis.zip_file_contents[new_file_name] = file
|
|
95
|
+
page.vis.load_master_pages() # load copied master page files into VisioFile object
|
|
96
|
+
# document-level masters relationship
|
|
97
|
+
page.vis._add_document_rel(
|
|
98
|
+
rel_type="http://schemas.microsoft.com/visio/2010/relationships/masters", target="masters/masters.xml"
|
|
99
|
+
)
|
|
100
|
+
# content-type overrides for masters.xml and master1.xml
|
|
101
|
+
page.vis._add_content_types_override(
|
|
102
|
+
content_type="application/vnd.ms-visio.masters+xml", part_name_path="/visio/masters/masters.xml"
|
|
103
|
+
)
|
|
104
|
+
page.vis._add_content_types_override(
|
|
105
|
+
content_type="application/vnd.ms-visio.master+xml", part_name_path="/visio/masters/master1.xml"
|
|
106
|
+
)
|
|
107
|
+
# per-page master relationship (creates + registers the page
|
|
108
|
+
# rels part so save_vsdx persists it)
|
|
109
|
+
page._ensure_page_master_rel("rId1", "master1.xml")
|
|
110
|
+
else:
|
|
111
|
+
# document has masters: import the connector master (by name)
|
|
112
|
+
# BEFORE the copy, while media_shape still points at its source
|
|
113
|
+
new_master_id = page.vis._ensure_masters_for_shape(media_shape) or None
|
|
114
|
+
|
|
115
|
+
connector_shape = media_shape.copy(page) # default to straight connector
|
|
116
|
+
connector_shape.text = "" # clear text used to find shape
|
|
117
|
+
if new_master_id:
|
|
118
|
+
# repoint the copied shape at this document's imported master
|
|
119
|
+
connector_shape.xml.attrib["Master"] = new_master_id
|
|
120
|
+
connector_shape.master_page_ID = new_master_id
|
|
121
|
+
|
|
122
|
+
# per-page relationship for whichever master the connector uses
|
|
123
|
+
effective_master_id = new_master_id or connector_shape.master_page_ID
|
|
124
|
+
master_page = page.vis.get_master_page_by_id(effective_master_id) if effective_master_id else None
|
|
125
|
+
if master_page is not None:
|
|
126
|
+
master_part = master_page.filename.replace(page.vis._masters_folder + "/", "")
|
|
127
|
+
page._ensure_page_master_rel(master_page.rel_id, master_part)
|
|
128
|
+
|
|
129
|
+
# TitlesOfParts entry for the master name (app.xml 'Masters' count
|
|
130
|
+
# is deliberately not written: real Visio packages omit it)
|
|
131
|
+
shape_name = connector_shape.shape_name
|
|
132
|
+
if shape_name and shape_name not in page.vis._titles_of_parts_list():
|
|
133
|
+
page.vis._add_titles_of_parts_item(shape_name)
|
|
134
|
+
|
|
135
|
+
# copy style used by new connector shape
|
|
136
|
+
master_shape = connector_shape.master_shape
|
|
137
|
+
line_style_id = master_shape.line_style_id if master_shape is not None else None
|
|
138
|
+
if line_style_id is not None and not isinstance(page.vis._get_style_by_id(line_style_id), Element):
|
|
139
|
+
# assume same if is ok, todo: use names for match and increment IDs
|
|
140
|
+
media_style = media.media._get_style_by_id(line_style_id)
|
|
141
|
+
if media_style is not None:
|
|
142
|
+
# copy, not alias: the donor document now outlives this
|
|
143
|
+
# call, so appending its live element would leave the two
|
|
144
|
+
# documents sharing one mutable StyleSheet
|
|
145
|
+
page.vis._style_sheets().append(copy.deepcopy(media_style))
|
|
146
|
+
|
|
147
|
+
# wire glue to the from/to shapes (Visio-faithful formulas, see
|
|
148
|
+
# tests/fixtures/com_reference/manifest.json for ground truth)
|
|
149
|
+
Connect._apply_glue(connector_shape, from_shape, to_shape, route=route, from_cp=from_cp, to_cp=to_cp)
|
|
150
|
+
|
|
151
|
+
# initial endpoints so the file renders sensibly even before Visio recalculates
|
|
152
|
+
connector_shape.set_start_and_finish(from_shape.center_x_y, to_shape.center_x_y)
|
|
153
|
+
return connector_shape
|
|
154
|
+
raise ValueError("Connect.create() requires both from_shape and to_shape")
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _get_or_create_cell(shape: Shape, name: str, v: str | None = None, f: str | None = None):
|
|
158
|
+
"""Set or create a cell on a shape, preserving schema cell ordering.
|
|
159
|
+
|
|
160
|
+
Delegates to the single cell-write primitive on Shape.
|
|
161
|
+
"""
|
|
162
|
+
return shape.get_or_create_cell(name, v=v, f=f)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _connection_point_count(shape: Shape) -> int:
|
|
166
|
+
sections = shape.xml.findall(f"{vsdx.namespace}Section")
|
|
167
|
+
for section in sections:
|
|
168
|
+
if section.attrib.get("N") == "Connection":
|
|
169
|
+
return len(section.findall(f"{vsdx.namespace}Row"))
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _validate_point_glue(from_shape: Shape, to_shape: Shape, route: str, from_cp: int, to_cp: int) -> None:
|
|
174
|
+
"""Validate route and point-glue indices before any package mutation.
|
|
175
|
+
|
|
176
|
+
Issue #9: Connect.create() provisioned masters and appended the
|
|
177
|
+
connector, and retarget() removed existing records, before
|
|
178
|
+
_apply_glue() rejected invalid indices — a caught ValueError left the
|
|
179
|
+
package half-changed. Everything _apply_glue can reject is checked
|
|
180
|
+
here so callers can fail before their first mutation.
|
|
181
|
+
"""
|
|
182
|
+
point_glue, _routing = Connect._parse_route(route)
|
|
183
|
+
if not point_glue:
|
|
184
|
+
return
|
|
185
|
+
for shape, cp in ((from_shape, from_cp), (to_shape, to_cp)):
|
|
186
|
+
cp_count = Connect._connection_point_count(shape)
|
|
187
|
+
if cp < 0 or cp >= cp_count:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Shape ID {shape.ID} has {cp_count} connection point(s); cannot glue to connection point index {cp}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _apply_glue(
|
|
194
|
+
connector_shape: Shape, from_shape: Shape, to_shape: Shape, route: str = "dynamic", from_cp: int = 0, to_cp: int = 0
|
|
195
|
+
):
|
|
196
|
+
"""Apply Visio-faithful glue between connector and from/to shapes.
|
|
197
|
+
|
|
198
|
+
Shape glue (default): _WALKGLUE formulas + GlueType=2, matching what
|
|
199
|
+
Visio writes for a dynamic connector glued to shape PinX.
|
|
200
|
+
Point glue (route='point'): PAR(PNT(...)) formulas referencing
|
|
201
|
+
Connections.Xn/Yn rows; raises ValueError if the shape has too few
|
|
202
|
+
connection points.
|
|
203
|
+
route may also set routing behaviour: 'straight' (ShapeRouteStyle=16),
|
|
204
|
+
'rightangle' (ShapeRouteStyle=1), 'curved' (ShapeRouteStyle=17 +
|
|
205
|
+
ConLineRouteExt=2).
|
|
206
|
+
"""
|
|
207
|
+
conn_id = connector_shape.ID
|
|
208
|
+
point_glue, routing = Connect._parse_route(route)
|
|
209
|
+
|
|
210
|
+
if point_glue:
|
|
211
|
+
ends = (("Begin", "EndX", from_shape, from_cp), ("End", "BeginX", to_shape, to_cp))
|
|
212
|
+
for prefix, _opposite_cell, shape, cp in ends:
|
|
213
|
+
cp_count = Connect._connection_point_count(shape)
|
|
214
|
+
if cp >= cp_count:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Shape ID {shape.ID} has {cp_count} connection point(s); cannot glue to connection point index {cp}"
|
|
217
|
+
)
|
|
218
|
+
k = cp + 1
|
|
219
|
+
Connect._get_or_create_cell(connector_shape, f"{prefix}Trigger", f=f"_XFTRIGGER(Sheet{shape.ID}!EventXFMod)")
|
|
220
|
+
pnt = f"PAR(PNT(Sheet{shape.ID}!Connections.X{k},Sheet{shape.ID}!Connections.Y{k}))"
|
|
221
|
+
Connect._get_or_create_cell(connector_shape, f"{prefix}X", f=pnt)
|
|
222
|
+
Connect._get_or_create_cell(connector_shape, f"{prefix}Y", f=pnt)
|
|
223
|
+
beg_connect = (
|
|
224
|
+
f'<Connect xmlns="http://schemas.microsoft.com/office/visio/2012/main" '
|
|
225
|
+
f'FromSheet="{conn_id}" FromCell="BeginX" FromPart="9" '
|
|
226
|
+
f'ToSheet="{from_shape.ID}" ToCell="Connections.X{from_cp + 1}" '
|
|
227
|
+
f'ToPart="{99 + from_cp + 1}"/>'
|
|
228
|
+
)
|
|
229
|
+
end_connect = (
|
|
230
|
+
f'<Connect xmlns="http://schemas.microsoft.com/office/visio/2012/main" '
|
|
231
|
+
f'FromSheet="{conn_id}" FromCell="EndX" FromPart="12" '
|
|
232
|
+
f'ToSheet="{to_shape.ID}" ToCell="Connections.X{to_cp + 1}" '
|
|
233
|
+
f'ToPart="{99 + to_cp + 1}"/>'
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# shape glue - dynamic connector behaviour, formulas as written by Visio 16
|
|
237
|
+
Connect._get_or_create_cell(connector_shape, "BegTrigger", f=f"_XFTRIGGER(Sheet{from_shape.ID}!EventXFMod)")
|
|
238
|
+
Connect._get_or_create_cell(connector_shape, "EndTrigger", f=f"_XFTRIGGER(Sheet{to_shape.ID}!EventXFMod)")
|
|
239
|
+
walkglue_begin = "_WALKGLUE(BegTrigger,EndTrigger,WalkPreference)"
|
|
240
|
+
walkglue_end = "_WALKGLUE(EndTrigger,BegTrigger,WalkPreference)"
|
|
241
|
+
Connect._get_or_create_cell(connector_shape, "BeginX", f=walkglue_begin)
|
|
242
|
+
Connect._get_or_create_cell(connector_shape, "BeginY", f=walkglue_begin)
|
|
243
|
+
Connect._get_or_create_cell(connector_shape, "EndX", f=walkglue_end)
|
|
244
|
+
Connect._get_or_create_cell(connector_shape, "EndY", f=walkglue_end)
|
|
245
|
+
Connect._get_or_create_cell(connector_shape, "GlueType", v="2")
|
|
246
|
+
Connect._get_or_create_cell(connector_shape, "ObjType", v="2")
|
|
247
|
+
# explicit dynamic routing, as written by Visio 16; overrides the
|
|
248
|
+
# template connector's inherited ShapeRouteStyle=16 (straight)
|
|
249
|
+
Connect._get_or_create_cell(connector_shape, "ShapeRouteStyle", v="0")
|
|
250
|
+
Connect._get_or_create_cell(connector_shape, "ConLineRouteExt", v="0")
|
|
251
|
+
Connect._get_or_create_cell(connector_shape, "ConFixedCode", v="6")
|
|
252
|
+
beg_connect = (
|
|
253
|
+
f'<Connect xmlns="http://schemas.microsoft.com/office/visio/2012/main" '
|
|
254
|
+
f'FromSheet="{conn_id}" FromCell="BeginX" FromPart="9" '
|
|
255
|
+
f'ToSheet="{from_shape.ID}" ToCell="PinX" ToPart="3"/>'
|
|
256
|
+
)
|
|
257
|
+
end_connect = (
|
|
258
|
+
f'<Connect xmlns="http://schemas.microsoft.com/office/visio/2012/main" '
|
|
259
|
+
f'FromSheet="{conn_id}" FromCell="EndX" FromPart="12" '
|
|
260
|
+
f'ToSheet="{to_shape.ID}" ToCell="PinX" ToPart="3"/>'
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if routing == "straight":
|
|
264
|
+
Connect._get_or_create_cell(connector_shape, "ShapeRouteStyle", v="16")
|
|
265
|
+
elif routing == "rightangle":
|
|
266
|
+
Connect._get_or_create_cell(connector_shape, "ShapeRouteStyle", v="1")
|
|
267
|
+
elif routing == "curved":
|
|
268
|
+
Connect._get_or_create_cell(connector_shape, "ShapeRouteStyle", v="17")
|
|
269
|
+
Connect._get_or_create_cell(connector_shape, "ConLineRouteExt", v="2")
|
|
270
|
+
|
|
271
|
+
# Add these new connection relationships to the page
|
|
272
|
+
page = connector_shape.page
|
|
273
|
+
page.add_connect(Connect(xml=ET.fromstring(end_connect), page=page))
|
|
274
|
+
page.add_connect(Connect(xml=ET.fromstring(beg_connect), page=page))
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def retarget(
|
|
278
|
+
page: vsdx.Page,
|
|
279
|
+
connector_shape: Shape,
|
|
280
|
+
from_shape: Shape | None = None,
|
|
281
|
+
to_shape: Shape | None = None,
|
|
282
|
+
route: str = "dynamic",
|
|
283
|
+
from_cp: int = 0,
|
|
284
|
+
to_cp: int = 0,
|
|
285
|
+
) -> Shape:
|
|
286
|
+
"""Retarget an existing connector to new endpoints.
|
|
287
|
+
|
|
288
|
+
Only the ends provided are moved; the other end keeps its current
|
|
289
|
+
glue (resolved from the page's existing Connect records). Reuses
|
|
290
|
+
_apply_glue for cells and records — removal of the old records goes
|
|
291
|
+
through the page's single record-removal path.
|
|
292
|
+
|
|
293
|
+
:returns: the connector Shape
|
|
294
|
+
"""
|
|
295
|
+
current_from = current_to = None
|
|
296
|
+
current_from_cp = current_to_cp = 0
|
|
297
|
+
for connect in page.connects:
|
|
298
|
+
if connect.from_id == str(connector_shape.ID):
|
|
299
|
+
# note: deliberately not named to_shape - that is the parameter
|
|
300
|
+
connected_shape = page.find_shape_by_id(connect.to_id) if connect.to_id else None
|
|
301
|
+
if connect.from_rel == "BeginX":
|
|
302
|
+
current_from = connected_shape
|
|
303
|
+
if connect.to_rel and connect.to_rel.startswith("Connections"):
|
|
304
|
+
current_from_cp = int(connect.to_rel.rsplit(".", 1)[1]) - 1
|
|
305
|
+
elif connect.from_rel == "EndX":
|
|
306
|
+
current_to = connected_shape
|
|
307
|
+
if connect.to_rel and connect.to_rel.startswith("Connections"):
|
|
308
|
+
current_to_cp = int(connect.to_rel.rsplit(".", 1)[1]) - 1
|
|
309
|
+
new_from = from_shape if from_shape is not None else current_from
|
|
310
|
+
new_to = to_shape if to_shape is not None else current_to
|
|
311
|
+
if new_from is None or new_to is None:
|
|
312
|
+
raise ValueError("connector has no resolvable endpoints to keep")
|
|
313
|
+
|
|
314
|
+
# validate everything _apply_glue can reject BEFORE removing the
|
|
315
|
+
# existing records (issue #9 atomicity)
|
|
316
|
+
Connect._validate_point_glue(
|
|
317
|
+
new_from,
|
|
318
|
+
new_to,
|
|
319
|
+
route,
|
|
320
|
+
from_cp if from_shape is not None else current_from_cp,
|
|
321
|
+
to_cp if to_shape is not None else current_to_cp,
|
|
322
|
+
)
|
|
323
|
+
page.remove_connect_records({str(connector_shape.ID)})
|
|
324
|
+
Connect._apply_glue(
|
|
325
|
+
connector_shape,
|
|
326
|
+
new_from,
|
|
327
|
+
new_to,
|
|
328
|
+
route=route,
|
|
329
|
+
from_cp=from_cp if from_shape is not None else current_from_cp,
|
|
330
|
+
to_cp=to_cp if to_shape is not None else current_to_cp,
|
|
331
|
+
)
|
|
332
|
+
connector_shape.set_start_and_finish(new_from.center_x_y, new_to.center_x_y)
|
|
333
|
+
return connector_shape
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def shape_id(self) -> str | None:
|
|
337
|
+
# ref to the shape where the connector terminates - convenience property
|
|
338
|
+
return self.to_id
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def shape(self) -> Shape | None:
|
|
342
|
+
return self.page.find_shape_by_id(self.shape_id) if self.shape_id else None
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def connector_shape_id(self) -> str | None:
|
|
346
|
+
# ref to the connector shape - convenience property
|
|
347
|
+
return self.from_id
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def connector_shape(self) -> Shape | None:
|
|
351
|
+
return self.page.find_shape_by_id(self.connector_shape_id) if self.connector_shape_id else None
|
|
352
|
+
|
|
353
|
+
def __repr__(self):
|
|
354
|
+
return f"Connect: from={self.from_id} to={self.to_id} connector_id={self.connector_shape_id} shape_id={self.shape_id}"
|
vsdx/containers.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Container and swimlane support for vsdx documents.
|
|
2
|
+
|
|
3
|
+
Ground truth: tests/fixtures/com_reference/s05_swimlanes_cfflow.vsdx
|
|
4
|
+
(Visio 16 cross-functional flowchart capture).
|
|
5
|
+
|
|
6
|
+
Model (verified against the capture):
|
|
7
|
+
- CFF shapes (CFF Container, Swimlane List, Swimlane lanes, Phase List,
|
|
8
|
+
Separator, and all flowchart shapes) live as TOP-LEVEL shapes on the page;
|
|
9
|
+
Visio keeps them flat and links them logically
|
|
10
|
+
- lane membership is GEOMETRIC: a shape belongs to the lane whose vertical
|
|
11
|
+
band contains the shape's centre (PinY). No membership cells exist
|
|
12
|
+
- each lane carries User-section rows: ``visHeadingText`` (label) and
|
|
13
|
+
``SwimlaneListGUID``; the heading text also appears in the lane's heading
|
|
14
|
+
sub-shape (the child carrying ``MasterShape``)
|
|
15
|
+
- lanes stack at a fixed pitch; the observed pitch is 1.1811 inches (30 mm)
|
|
16
|
+
- User rows are stored as ``<Section N='User'><Row N='name'>`` which is NOT
|
|
17
|
+
the same as plain ``<Cell N='...'>`` cells; helpers here handle both
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import xml.etree.ElementTree as ET
|
|
23
|
+
|
|
24
|
+
import vsdx
|
|
25
|
+
|
|
26
|
+
from .shapes import Shape
|
|
27
|
+
|
|
28
|
+
# observed lane pitch in the Visio 16 CFF capture (inches)
|
|
29
|
+
LANE_PITCH_INCHES = 1.18110236220472
|
|
30
|
+
|
|
31
|
+
# User-section row names written by Visio on lane shapes
|
|
32
|
+
ROW_HEADING_TEXT = "visHeadingText"
|
|
33
|
+
ROW_SWIMLANE_GUID = "SwimlaneListGUID"
|
|
34
|
+
|
|
35
|
+
# top-level shape NameU values of the CFF machinery (excluded from membership)
|
|
36
|
+
_CFF_MACHINERY = ("CFF Container", "Swimlane List", "Phase List", "Separator")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_user_row(shape: Shape, name: str) -> ET.Element | None:
|
|
40
|
+
"""Return the ``<Row N=name>`` element of the shape's User section, or None."""
|
|
41
|
+
for section in shape.xml.findall(f"{vsdx.namespace}Section"):
|
|
42
|
+
if section.attrib.get("N") == "User":
|
|
43
|
+
for row in section.findall(f"{vsdx.namespace}Row"):
|
|
44
|
+
if row.attrib.get("N") == name:
|
|
45
|
+
return row
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_user_row_value(shape: Shape, name: str, value: str) -> bool:
|
|
50
|
+
"""Set the Value cell of a User-section row. Returns False if the row is
|
|
51
|
+
absent (rows are only created where Visio itself creates them)."""
|
|
52
|
+
row = get_user_row(shape, name)
|
|
53
|
+
if row is None:
|
|
54
|
+
return False
|
|
55
|
+
for cell in row.findall(f"{vsdx.namespace}Cell"):
|
|
56
|
+
if cell.attrib.get("N") == "Value":
|
|
57
|
+
cell.attrib["V"] = value
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Container:
|
|
63
|
+
"""Read/write view over a CFF (swimlane) diagram structure.
|
|
64
|
+
|
|
65
|
+
Membership is geometric: :meth:`lane_of` maps a shape to the lane whose
|
|
66
|
+
vertical band contains its centre, mirroring Visio's own containment
|
|
67
|
+
behaviour. There are no membership cells to write.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, page: vsdx.Page):
|
|
71
|
+
self.page = page
|
|
72
|
+
|
|
73
|
+
# ---- discovery -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def find(page: vsdx.Page) -> Container | None:
|
|
77
|
+
"""Return a Container for the page, or None if this is not a CFF page."""
|
|
78
|
+
for shape in page.all_shapes:
|
|
79
|
+
if shape.shape_name == "CFF Container":
|
|
80
|
+
return Container(page)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _top_level_named(self, name_prefix: str) -> list[Shape]:
|
|
84
|
+
shapes_tag = self.page.xml.find(f"{vsdx.namespace}Shapes")
|
|
85
|
+
if shapes_tag is None:
|
|
86
|
+
return []
|
|
87
|
+
result = []
|
|
88
|
+
for el in shapes_tag.findall(f"{vsdx.namespace}Shape"):
|
|
89
|
+
name = el.attrib.get("NameU") or el.attrib.get("Name") or ""
|
|
90
|
+
if name.startswith(name_prefix):
|
|
91
|
+
result.append(Shape(xml=el, parent=self.page, page=self.page))
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def container_shape(self) -> Shape | None:
|
|
96
|
+
matches = self._top_level_named("CFF Container")
|
|
97
|
+
return matches[0] if matches else None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def swimlane_list(self) -> Shape | None:
|
|
101
|
+
matches = self._top_level_named("Swimlane List")
|
|
102
|
+
return matches[0] if matches else None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def lanes(self) -> list[Shape]:
|
|
106
|
+
"""Lane shapes in visual order, top lane first."""
|
|
107
|
+
lanes = [
|
|
108
|
+
s
|
|
109
|
+
for s in self._top_level_named("Swimlane")
|
|
110
|
+
if s.shape_name and s.shape_name.startswith("Swimlane") and not s.shape_name.startswith("Swimlane List")
|
|
111
|
+
]
|
|
112
|
+
lanes.sort(key=lambda s: -(s.y or 0.0)) # top-to-bottom
|
|
113
|
+
return lanes
|
|
114
|
+
|
|
115
|
+
# ---- geometry --------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def lane_band(lane: Shape) -> tuple[float, float]:
|
|
119
|
+
"""(bottom, top) Y band of a lane, from its centre and height."""
|
|
120
|
+
centre = lane.y or 0.0
|
|
121
|
+
height = lane.height or LANE_PITCH_INCHES
|
|
122
|
+
return centre - height / 2, centre + height / 2
|
|
123
|
+
|
|
124
|
+
def lane_of(self, shape: Shape) -> Shape | None:
|
|
125
|
+
"""The lane whose band contains the shape's centre, or None."""
|
|
126
|
+
for lane in self.lanes:
|
|
127
|
+
bottom, top = self.lane_band(lane)
|
|
128
|
+
if bottom <= (shape.y or 0.0) <= top:
|
|
129
|
+
return lane
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def members(self, lane: Shape) -> list[Shape]:
|
|
133
|
+
"""Flowchart shapes whose centre lies in the lane's band."""
|
|
134
|
+
bottom, top = self.lane_band(lane)
|
|
135
|
+
result = []
|
|
136
|
+
for shape in self._top_level_named(""): # all top-level shapes
|
|
137
|
+
name = shape.shape_name or ""
|
|
138
|
+
if name.startswith(_CFF_MACHINERY) or name.startswith("Swimlane"):
|
|
139
|
+
continue
|
|
140
|
+
if "BeginX" in shape.cells: # connectors are not members
|
|
141
|
+
continue
|
|
142
|
+
if bottom <= (shape.y or 0.0) <= top:
|
|
143
|
+
result.append(shape)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# ---- operations ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def add_swimlane(self, label: str | None = None) -> Shape:
|
|
149
|
+
"""Add a lane above the current top lane by cloning it and shifting
|
|
150
|
+
one lane pitch. The Swimlane List and CFF Container grow to match.
|
|
151
|
+
|
|
152
|
+
:return: the new lane Shape
|
|
153
|
+
"""
|
|
154
|
+
lanes = self.lanes
|
|
155
|
+
if not lanes:
|
|
156
|
+
raise ValueError("page has no Swimlane lanes; not a CFF diagram")
|
|
157
|
+
top_lane = lanes[0]
|
|
158
|
+
|
|
159
|
+
new_xml = vsdx.ET.fromstring(vsdx.ET.tostring(top_lane.xml))
|
|
160
|
+
shapes_tag = self.page.xml.find(f"{vsdx.namespace}Shapes")
|
|
161
|
+
if shapes_tag is None:
|
|
162
|
+
raise ValueError("page has no Shapes tag")
|
|
163
|
+
self.page.set_max_ids() # ensure max_id reflects existing shapes
|
|
164
|
+
id_map = self.page.vis.increment_shape_ids(new_xml, self.page)
|
|
165
|
+
self.page.vis.update_ids(new_xml, id_map)
|
|
166
|
+
shapes_tag.append(new_xml)
|
|
167
|
+
new_lane = Shape(xml=new_xml, parent=self.page, page=self.page)
|
|
168
|
+
|
|
169
|
+
new_lane.get_or_create_cell("PinY", v=str((top_lane.y or 0.0) + LANE_PITCH_INCHES))
|
|
170
|
+
|
|
171
|
+
# grow the list and container so the new lane sits inside them
|
|
172
|
+
pitch = LANE_PITCH_INCHES
|
|
173
|
+
lane_list = self.swimlane_list
|
|
174
|
+
if lane_list is not None:
|
|
175
|
+
lane_list.get_or_create_cell("PinY", v=str((lane_list.y or 0.0) + pitch / 2))
|
|
176
|
+
lane_list.get_or_create_cell("Height", v=str((lane_list.height or 0) + pitch))
|
|
177
|
+
container = self.container_shape
|
|
178
|
+
if container is not None:
|
|
179
|
+
container.get_or_create_cell("PinY", v=str((container.y or 0.0) + pitch / 2))
|
|
180
|
+
container.get_or_create_cell("Height", v=str((container.height or 0) + pitch))
|
|
181
|
+
|
|
182
|
+
if label:
|
|
183
|
+
self.set_lane_label(new_lane, label)
|
|
184
|
+
self.page.set_max_ids()
|
|
185
|
+
return new_lane
|
|
186
|
+
|
|
187
|
+
def set_lane_label(self, lane: Shape, label: str) -> None:
|
|
188
|
+
"""Set a lane's heading label (visHeadingText row + heading text)."""
|
|
189
|
+
set_user_row_value(lane, ROW_HEADING_TEXT, label)
|
|
190
|
+
heading = self.lane_heading(lane)
|
|
191
|
+
if heading is not None:
|
|
192
|
+
heading.text = label
|
|
193
|
+
else:
|
|
194
|
+
lane.text = label
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def lane_heading(lane: Shape) -> Shape | None:
|
|
198
|
+
"""The lane's heading sub-shape (child carrying MasterShape)."""
|
|
199
|
+
for child in lane.child_shapes:
|
|
200
|
+
if child.master_shape_ID is not None:
|
|
201
|
+
return child
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def add_shape_to_lane(self, shape: Shape, lane: Shape) -> None:
|
|
205
|
+
"""Assign a shape to a lane by geometry: set the shape's PinY to the
|
|
206
|
+
lane's centre, keeping its PinX. Mirrors Visio's own behaviour when a
|
|
207
|
+
shape is dragged into a lane; membership stays geometric.
|
|
208
|
+
"""
|
|
209
|
+
if self.lane_of(shape) is lane:
|
|
210
|
+
return
|
|
211
|
+
shape.get_or_create_cell("PinY", v=str(lane.y or 0.0))
|