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 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))