harnice 0.3.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.
Files changed (41) hide show
  1. harnice/__init__.py +0 -0
  2. harnice/__main__.py +4 -0
  3. harnice/cli.py +234 -0
  4. harnice/fileio.py +295 -0
  5. harnice/gui/launcher.py +426 -0
  6. harnice/lists/channel_map.py +182 -0
  7. harnice/lists/circuits_list.py +302 -0
  8. harnice/lists/disconnect_map.py +237 -0
  9. harnice/lists/formboard_graph.py +63 -0
  10. harnice/lists/instances_list.py +280 -0
  11. harnice/lists/library_history.py +40 -0
  12. harnice/lists/manifest.py +93 -0
  13. harnice/lists/post_harness_instances_list.py +66 -0
  14. harnice/lists/rev_history.py +325 -0
  15. harnice/lists/signals_list.py +135 -0
  16. harnice/products/__init__.py +1 -0
  17. harnice/products/cable.py +152 -0
  18. harnice/products/chtype.py +80 -0
  19. harnice/products/device.py +844 -0
  20. harnice/products/disconnect.py +225 -0
  21. harnice/products/flagnote.py +139 -0
  22. harnice/products/harness.py +522 -0
  23. harnice/products/macro.py +10 -0
  24. harnice/products/part.py +640 -0
  25. harnice/products/system.py +125 -0
  26. harnice/products/tblock.py +270 -0
  27. harnice/state.py +57 -0
  28. harnice/utils/appearance.py +51 -0
  29. harnice/utils/circuit_utils.py +326 -0
  30. harnice/utils/feature_tree_utils.py +183 -0
  31. harnice/utils/formboard_utils.py +973 -0
  32. harnice/utils/library_utils.py +333 -0
  33. harnice/utils/note_utils.py +417 -0
  34. harnice/utils/svg_utils.py +819 -0
  35. harnice/utils/system_utils.py +563 -0
  36. harnice-0.3.0.dist-info/METADATA +32 -0
  37. harnice-0.3.0.dist-info/RECORD +41 -0
  38. harnice-0.3.0.dist-info/WHEEL +5 -0
  39. harnice-0.3.0.dist-info/entry_points.txt +3 -0
  40. harnice-0.3.0.dist-info/licenses/LICENSE +19 -0
  41. harnice-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,819 @@
1
+ import os
2
+ import re
3
+ import math
4
+ from harnice.utils import appearance
5
+
6
+
7
+ def add_entire_svg_file_contents_to_group(filepath, new_group_name):
8
+ """
9
+ Wraps the entire contents of an SVG file in a new group element.
10
+
11
+ Reads an SVG file, extracts its inner content (everything between `<svg>` tags),
12
+ and wraps it in a new group element with start and end markers. The original
13
+ file is modified in place.
14
+
15
+ **Args:**
16
+ - `filepath` (str): Path to the SVG file to modify.
17
+ - `new_group_name` (str): Name to use for the new group element (will create
18
+ `{new_group_name}-contents-start` and `{new_group_name}-contents-end` markers).
19
+
20
+ **Raises:**
21
+ - `ValueError`: If the file does not appear to be a valid SVG or has no inner contents.
22
+ """
23
+ if os.path.exists(filepath):
24
+ try:
25
+ with open(filepath, "r", encoding="utf-8") as file:
26
+ svg_content = file.read()
27
+
28
+ match = re.search(r"<svg[^>]*>(.*?)</svg>", svg_content, re.DOTALL)
29
+ if not match:
30
+ raise ValueError(
31
+ "File does not appear to be a valid SVG or has no inner contents."
32
+ )
33
+ inner_content = match.group(1).strip()
34
+
35
+ updated_svg_content = (
36
+ f'<svg xmlns="http://www.w3.org/2000/svg">\n'
37
+ f' <g id="{new_group_name}-contents-start">\n'
38
+ f" {inner_content}\n"
39
+ f" </g>\n"
40
+ f' <g id="{new_group_name}-contents-end"></g>\n'
41
+ f"</svg>\n"
42
+ )
43
+
44
+ with open(filepath, "w", encoding="utf-8") as file:
45
+ file.write(updated_svg_content)
46
+
47
+ except Exception as e:
48
+ print(
49
+ f"Error adding contents of {os.path.basename(filepath)} to a new group {new_group_name}: {e}"
50
+ )
51
+ else:
52
+ print(
53
+ f"Trying to add contents of {os.path.basename(filepath)} to a new group but file does not exist."
54
+ )
55
+
56
+
57
+ def find_and_replace_svg_group(
58
+ source_svg_filepath,
59
+ source_group_name,
60
+ destination_svg_filepath,
61
+ destination_group_name,
62
+ ):
63
+ """
64
+ Copies SVG group content from one file to another, replacing existing group content.
65
+
66
+ Extracts the content between group markers in a source SVG file and replaces
67
+ the content between corresponding markers in a destination SVG file. The group
68
+ markers are identified by `{group_name}-contents-start` and `{group_name}-contents-end` IDs.
69
+
70
+ **Args:**
71
+ - `source_svg_filepath` (str): Path to the source SVG file containing the group to copy.
72
+ - `source_group_name` (str): Name of the source group to extract content from.
73
+ - `destination_svg_filepath` (str): Path to the destination SVG file to modify.
74
+ - `destination_group_name` (str): Name of the destination group to replace content in.
75
+
76
+ **Returns:**
77
+ - `int`: Always returns `1` (success indicator).
78
+
79
+ **Raises:**
80
+ - `ValueError`: If any of the required group markers are not found in the source
81
+ or destination files.
82
+ """
83
+ with open(source_svg_filepath, "r", encoding="utf-8") as source_file:
84
+ source_svg_content = source_file.read()
85
+ with open(destination_svg_filepath, "r", encoding="utf-8") as target_file:
86
+ target_svg_content = target_file.read()
87
+
88
+ source_start_tag = f'id="{source_group_name}-contents-start"'
89
+ source_end_tag = f'id="{source_group_name}-contents-end"'
90
+ dest_start_tag = f'id="{destination_group_name}-contents-start"'
91
+ dest_end_tag = f'id="{destination_group_name}-contents-end"'
92
+
93
+ source_start_index = source_svg_content.find(source_start_tag)
94
+ if source_start_index == -1:
95
+ raise ValueError(
96
+ f"[ERROR] Source start tag <{source_start_tag}> not found in <{source_svg_filepath}>."
97
+ )
98
+ source_start_index = source_svg_content.find(">", source_start_index) + 1
99
+
100
+ source_end_index = source_svg_content.find(source_end_tag)
101
+ if source_end_index == -1:
102
+ raise ValueError(
103
+ f"[ERROR] Source end tag <{source_end_tag}> not found in <{source_svg_filepath}>."
104
+ )
105
+ source_end_index = source_svg_content.rfind("<", 0, source_end_index)
106
+
107
+ dest_start_index = target_svg_content.find(dest_start_tag)
108
+ if dest_start_index == -1:
109
+ raise ValueError(
110
+ f"[ERROR] Target start tag <{dest_start_tag}> not found in <{destination_svg_filepath}>."
111
+ )
112
+ dest_start_index = target_svg_content.find(">", dest_start_index) + 1
113
+
114
+ dest_end_index = target_svg_content.find(dest_end_tag)
115
+ if dest_end_index == -1:
116
+ raise ValueError(
117
+ f"[ERROR] Target end tag <{dest_end_tag}> not found in <{destination_svg_filepath}>."
118
+ )
119
+ dest_end_index = target_svg_content.rfind("<", 0, dest_end_index)
120
+
121
+ replacement_group_content = source_svg_content[source_start_index:source_end_index]
122
+
123
+ updated_svg_content = (
124
+ target_svg_content[:dest_start_index]
125
+ + replacement_group_content
126
+ + target_svg_content[dest_end_index:]
127
+ )
128
+
129
+ with open(destination_svg_filepath, "w", encoding="utf-8") as updated_file:
130
+ updated_file.write(updated_svg_content)
131
+
132
+ return 1
133
+
134
+
135
+ def draw_styled_path(spline_points, stroke_width_inches, appearance_dict, local_group):
136
+ """
137
+ Adds a styled spline path to the local group.
138
+ Call as if you were appending any other element to an svg group.
139
+
140
+ Spline points are a list of dictionaries with x and y coordinates. [{"x": 0, "y": 0, "tangent": 0}, {"x": 1, "y": 1, "tangent": 0}]
141
+ Appearance dictionary is a dictionary with the following keys: base_color, outline_color, parallelstripe, perpstripe, slash_lines
142
+ Slash lines dictionary is a dictionary with the following keys: direction, angle, step, color, slash_width_inches
143
+
144
+ If no appearance dictionary is provided, a rainbow spline will be drawn in place of the path.
145
+ """
146
+
147
+ if not appearance_dict:
148
+ appearance_dict = appearance.parse(
149
+ "{'base_color':'red', 'perpstripe':['orange','yellow','green','blue','purple']}"
150
+ )
151
+ stroke_width_inches = 0.01
152
+ else:
153
+ appearance_dict = appearance.parse(appearance_dict)
154
+
155
+ # ---------------------------------------------------------------------
156
+ # --- spline_to_path
157
+ # ---------------------------------------------------------------------
158
+ def spline_to_path(points):
159
+ if len(points) < 2:
160
+ return ""
161
+ path = f"M {points[0]['x']:.3f},{-points[0]['y']:.3f}"
162
+ for i in range(len(points) - 1):
163
+ p0, p1 = points[i], points[i + 1]
164
+ t0, t1 = math.radians(p0.get("tangent", 0)), math.radians(
165
+ p1.get("tangent", 0)
166
+ )
167
+ d = math.hypot(p1["x"] - p0["x"], p1["y"] - p0["y"])
168
+ ctrl_dist = d * 0.5
169
+ c1x = p0["x"] + math.cos(t0) * ctrl_dist
170
+ c1y = p0["y"] + math.sin(t0) * ctrl_dist
171
+ c2x = p1["x"] - math.cos(t1) * ctrl_dist
172
+ c2y = p1["y"] - math.sin(t1) * ctrl_dist
173
+ path += f" C {c1x:.3f},{-c1y:.3f} {c2x:.3f},{-c2y:.3f} {p1['x']:.3f},{-p1['y']:.3f}"
174
+ return path
175
+
176
+ # ---------------------------------------------------------------------
177
+ # --- draw consistent slanted hatches (twisted wire)
178
+ # ---------------------------------------------------------------------
179
+ def draw_slash_lines(points, slash_lines_dict):
180
+ if slash_lines_dict.get("direction") in ("RH", "LH"):
181
+ if slash_lines_dict.get("angle") is not None:
182
+ angle_slant = slash_lines_dict.get("angle")
183
+ else:
184
+ angle_slant = 20
185
+ if slash_lines_dict.get("step") is not None:
186
+ step_dist = slash_lines_dict.get("step")
187
+ else:
188
+ step_dist = stroke_width * 3
189
+ if slash_lines_dict.get("color") is not None:
190
+ color_line = slash_lines_dict.get("color")
191
+ else:
192
+ color_line = "black"
193
+ if slash_lines_dict.get("color") is not None:
194
+ color_line = slash_lines_dict.get("color")
195
+ else:
196
+ color_line = "black"
197
+ if slash_lines_dict.get("slash_width_inches") is not None:
198
+ slash_width = slash_lines_dict.get("slash_width_inches") * 96
199
+ else:
200
+ slash_width = 0.25
201
+
202
+ line_elements = []
203
+
204
+ def bezier_eval(p0, c1, c2, p1, t):
205
+ mt = 1 - t
206
+ x = (
207
+ (mt**3) * p0[0]
208
+ + 3 * (mt**2) * t * c1[0]
209
+ + 3 * mt * (t**2) * c2[0]
210
+ + (t**3) * p1[0]
211
+ )
212
+ y = (
213
+ (mt**3) * p0[1]
214
+ + 3 * (mt**2) * t * c1[1]
215
+ + 3 * mt * (t**2) * c2[1]
216
+ + (t**3) * p1[1]
217
+ )
218
+ dx = (
219
+ 3 * (mt**2) * (c1[0] - p0[0])
220
+ + 6 * mt * t * (c2[0] - c1[0])
221
+ + 3 * (t**2) * (p1[0] - c2[0])
222
+ )
223
+ dy = (
224
+ 3 * (mt**2) * (c1[1] - p0[1])
225
+ + 6 * mt * t * (c2[1] - c1[1])
226
+ + 3 * (t**2) * (p1[1] - c2[1])
227
+ )
228
+ return {"x": x, "y": y, "tangent": math.degrees(math.atan2(dy, dx))}
229
+
230
+ def bezier_length(p0, c1, c2, p1, samples=80):
231
+ prev = bezier_eval(p0, c1, c2, p1, 0)
232
+ L = 0.0
233
+ for i in range(1, samples + 1):
234
+ t = i / samples
235
+ pt = bezier_eval(p0, c1, c2, p1, t)
236
+ L += math.hypot(pt["x"] - prev["x"], pt["y"] - prev["y"])
237
+ prev = pt
238
+ return L
239
+
240
+ # -------------------------------------------------------------
241
+ # Iterate through Bézier segments
242
+ for i in range(len(points) - 1):
243
+ p0 = (points[i]["x"], points[i]["y"])
244
+ p1 = (points[i + 1]["x"], points[i + 1]["y"])
245
+ t0 = math.radians(points[i].get("tangent", 0))
246
+ t1 = math.radians(points[i + 1].get("tangent", 0))
247
+ d = math.hypot(p1[0] - p0[0], p1[1] - p0[1])
248
+ ctrl_dist = d * 0.5
249
+ c1 = (p0[0] + math.cos(t0) * ctrl_dist, p0[1] + math.sin(t0) * ctrl_dist)
250
+ c2 = (p1[0] - math.cos(t1) * ctrl_dist, p1[1] - math.sin(t1) * ctrl_dist)
251
+
252
+ L = bezier_length(p0, c1, c2, p1)
253
+ num_steps = max(1, int(L / step_dist))
254
+ step_dist_actual = L / num_steps # uniform spacing
255
+
256
+ for z in range(num_steps + 1):
257
+ # ------------------------------------------------------------------
258
+ # 1. Evaluate point along Bézier curve
259
+ # ------------------------------------------------------------------
260
+ t_norm = min(1.0, (z * step_dist_actual) / L)
261
+ P = bezier_eval(p0, c1, c2, p1, t_norm)
262
+
263
+ # Centerpoint on spline
264
+ center = (P["x"], P["y"])
265
+
266
+ # ------------------------------------------------------------------
267
+ # 2. Tangent and hatch angle computation
268
+ # ------------------------------------------------------------------
269
+ # Tangent direction of the spline at this point (radians)
270
+ tangent_angle = math.radians(P["tangent"])
271
+
272
+ # LH vs RH determines whether we add or subtract the slant
273
+ if slash_lines_dict.get("direction") == "LH":
274
+ line_angle = tangent_angle + math.radians(angle_slant)
275
+ else: # "RH"
276
+ line_angle = tangent_angle - math.radians(angle_slant)
277
+
278
+ # ------------------------------------------------------------------
279
+ # 3. Compute hatch line geometry
280
+ # ------------------------------------------------------------------
281
+ # Shorter lines at steep slant; normalize by cos(slant)
282
+ line_length = stroke_width / math.sin(math.radians(angle_slant))
283
+
284
+ # Vector along hatch direction
285
+ dx = math.cos(line_angle) * (line_length / 2)
286
+ dy = math.sin(line_angle) * (line_length / 2)
287
+
288
+ # Line endpoints
289
+ x1 = center[0] - dx
290
+ y1 = center[1] - dy
291
+ x2 = center[0] + dx
292
+ y2 = center[1] + dy
293
+
294
+ # ------------------------------------------------------------------
295
+ # 4. Append SVG element
296
+ # ------------------------------------------------------------------
297
+ line_elements.append(
298
+ f'<line x1="{x1:.2f}" y1="{-y1:.2f}" '
299
+ f'x2="{x2:.2f}" y2="{-y2:.2f}" '
300
+ f'stroke="{color_line}" stroke-width="{slash_width}" />'
301
+ )
302
+
303
+ local_group.extend(line_elements)
304
+
305
+ # ---------------------------------------------------------------------
306
+ # --- Main body rendering
307
+ # ---------------------------------------------------------------------
308
+ base_color = appearance_dict.get("base_color", "white")
309
+ outline_color = appearance_dict.get("outline_color")
310
+ path_d = spline_to_path(spline_points)
311
+
312
+ # outline path
313
+ stroke_width = stroke_width_inches * 96
314
+ if outline_color:
315
+ local_group.append(
316
+ f'<path d="{path_d}" stroke="{outline_color}" stroke-width="{stroke_width}" '
317
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
318
+ )
319
+ stroke_width = stroke_width - 0.5
320
+
321
+ # base path
322
+ local_group.append(
323
+ f'<path d="{path_d}" stroke="{base_color}" stroke-width="{stroke_width}" '
324
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
325
+ )
326
+
327
+ # ---------------------------------------------------------------------
328
+ # --- Add pattern overlays
329
+ # ---------------------------------------------------------------------
330
+ if appearance_dict.get("parallelstripe"):
331
+ stripes = appearance_dict["parallelstripe"]
332
+ num = len(stripes)
333
+ stripe_thickness = stroke_width / num
334
+ stripe_spacing = stroke_width / num
335
+ offset = -(num - 1) * stripe_spacing / 2
336
+ for color in stripes:
337
+ local_group.append(
338
+ f'<path d="{path_d}" stroke="{color}" '
339
+ f'stroke-width="{stripe_thickness}" fill="none" '
340
+ f'transform="translate(0,{offset:.2f})"/>'
341
+ )
342
+ offset += stripe_spacing
343
+
344
+ if appearance_dict.get("perpstripe"):
345
+ stripes = appearance_dict["perpstripe"]
346
+ num = len(stripes)
347
+ pattern_length = 30
348
+ dash = pattern_length / (num + 1)
349
+ gap = pattern_length - dash
350
+ offset = 0
351
+ for color in stripes:
352
+ offset += dash
353
+ local_group.append(
354
+ f'<path d="{path_d}" stroke="{color}" stroke-width="{stroke_width}" '
355
+ f'stroke-dasharray="{dash},{gap}" stroke-dashoffset="{offset}" '
356
+ f'fill="none" />'
357
+ )
358
+
359
+ # --- Slash lines ---
360
+ if appearance_dict.get("slash_lines") is not None:
361
+ slash_lines_dict = appearance_dict.get("slash_lines")
362
+ draw_slash_lines(spline_points, slash_lines_dict)
363
+
364
+
365
+ def table(layout_dict, format_dict, columns_list, content_list, path_to_svg, contents_group_name):
366
+ """
367
+ This function is called when the user needs to build a general SVG table.
368
+ ```python
369
+ svg_utils.table(
370
+ layout_dict,
371
+ format_dict,
372
+ columns_list,
373
+ content_list,
374
+ path_to_caller,
375
+ svg_name
376
+ )
377
+ ```
378
+ ### Arguments
379
+ - `layout_dict` expects a dictionary describing in which direction the table is built
380
+ - `format_dict` expects a dictionary containing a description of how you want your table to appear.
381
+ - `columns_list` expects a list containing your column header content, width, and formatting rules.
382
+ - `content_list` expects a list containing what is actually presented on your table.
383
+
384
+ ### Returns
385
+ - A string of SVG primatives in xml format intended to look like a table.
386
+
387
+ ---
388
+
389
+ ## 1. Layout
390
+
391
+ The SVG origin (0,0) must exist somewhere in your table. Defining this correctly will help later when tables dynamically update with changing inputs.
392
+
393
+ *example:*
394
+ ```json
395
+ layout = {
396
+ "origin_corner": "top-left",
397
+ "build_direction": "down",
398
+ }
399
+ ```
400
+ Both fields are required.
401
+ ### Origin Corner
402
+
403
+ The origin is defined to be at one of the four corners of the first row `content[0]`. Valid options:
404
+ - `top-left`
405
+ - `top-right`
406
+ - `bottom-left`
407
+ - `bottom-right`
408
+
409
+ ### Build Direction
410
+ When building a table, you can choose to build rows downwards (below the previous, positive y in svg coords) or upwards (above the previous, negative y in svg coords). The direction property defines this:
411
+ - `down` → rows appear below the previous
412
+ - `up` → new rows appear above the previous
413
+
414
+ ---
415
+
416
+ ## 2. Format
417
+
418
+ The format dictionary defines appearance and style of your table.
419
+
420
+ Any number of appearance keys can be defined and named with an identifier that is called when printing that row. This allows you to have rows that whose appearance can be varied dynamically with the table contents.
421
+
422
+ *example:*
423
+ ```json
424
+ format_dict={
425
+ "globals": {
426
+ "font_size": 11,
427
+ "row_height": 20,
428
+ },
429
+ "header": {
430
+ "font_weight":"B",
431
+ "fill_color": "lightgray",
432
+ },
433
+ "row_with_bubble": {
434
+ "row_height": 40,
435
+ },
436
+ }
437
+ ```
438
+ The only reserved key is `globals` which can optionally be used to define fallbacks for any row that does not have a style explicitly called out.
439
+
440
+ ### Format Arguments
441
+ Any of the following keys can be defined in any of the format dictionaries.
442
+ - `font_size` *(number, default=12)* Default font size (px) for all text
443
+ - `font_family` *(string, default=helvetica)* Default font family (e.g., "Arial", "Helvetica")
444
+ - `font_weight`*(`BIU`, default=None)* Add each character for bold, italic, or underline
445
+ - `row_height` *(number, default=18)* Self-explanatory (px)
446
+ - `padding` *(number, default=3)* Default text inset from border if not center or middle justified
447
+ - `line_spacing` *(number, default=14)* Vertical spacing between multi-line text entries
448
+ - `justify` *(`left` \ `center` \ `right`, default=center)* Default horizontal alignment
449
+ - `valign` *(`top` \ `middle` \ `bottom`, default=center)* Default vertical alignment
450
+ - `fill_color` *(default=white)* Cell background color
451
+ - `stroke_color` *(default=black)* Border line color
452
+ - `stroke_width` *(number, default=1)* Border width
453
+ - `text_color` *(default=black)* Default text color
454
+
455
+ ### Style Resolution Order
456
+ If something is defined at the row level, it takes precedent over some parameter defined at the column level, which takes precedent over a definition in key `globals`, if defined. If something is not defined at all, the above defaults will apply.
457
+
458
+ ### Color Standard
459
+ - Default color: **black**
460
+ - Accepted formats:
461
+ - Named SVG colors https://www.w3.org/TR/SVG11/types.html#ColorKeywords
462
+ - Hex values (#RGB or #RRGGBB)
463
+
464
+ ---
465
+
466
+ ## 3. Columns
467
+
468
+ The column argument is a list of dictionaries containing definition of how many columns there are, the order in which they exist, how to reference them, and any applicable formatting.
469
+
470
+ *ex:*
471
+ ```json
472
+ columns_list=[
473
+ {
474
+ "name": "rev"
475
+ "width": 60,
476
+ "justify": "center"
477
+ },
478
+ {
479
+ "name": "updated"
480
+ "width": 260,
481
+ },
482
+ "name": "status"
483
+ "width": 120,
484
+ "fill_color": "yellow",
485
+ }
486
+ ]
487
+ ```
488
+
489
+ ### Column Arguments
490
+ Each field must have the following required keys:
491
+ - `name` *(string)* Used to identify a column when defining contents later. Must be unique.
492
+ - `width` *(number)* Self-explanatory (px)
493
+
494
+ You may add any formatting key as defined in the formatting section as needed.
495
+
496
+ Note that the order of the items in the list represents the order in which they will be printed from left to right, regardless of the layout you've chosen for this table.
497
+
498
+ ---
499
+
500
+ ## 4. Content Structure
501
+
502
+ The table content will be referenced from information stored in this argument. It is a list (rows) of dictionaries (columns).
503
+
504
+ ```json
505
+ content_list = [
506
+ {
507
+ "format_key": "header"
508
+ "columns": {
509
+ "rev": "REV",
510
+ "updated": "UPDATED",
511
+ "status": "STATUS",
512
+ }
513
+ },
514
+ {
515
+ "columns": {
516
+ "rev": "1",
517
+ "updated": "12/6/25",
518
+ "status": "requires review",
519
+ }
520
+ },
521
+ {
522
+ "columns": {
523
+ "rev": "2",
524
+ "updated": ["12/6/25", "update incomplete"],
525
+ }
526
+ },
527
+ {
528
+ "format_key": "row_with_bubble",
529
+ "columns": {
530
+ "rev": {
531
+ "instance_name": "rev3-bubble",
532
+ "item_type": "flagnote"
533
+ },
534
+ "updated": "12/6/25",
535
+ "status": "clear"
536
+ }
537
+ }
538
+ ]
539
+ ```
540
+
541
+ Content (the root argument) is a list. Each entry of the root list is representative of a row's worth of data.
542
+
543
+ Each entry of that list must contain the dictionary `columns` and may contain dictionary `format_key`.
544
+
545
+ `format_key` may only contain one value which corresponds to the name of a key in the format dictionary. It represents the appearance of that row. If it is not defined, the format of that row will fall back to globals and defaults. Custom formatting of individual cells is not supported.
546
+
547
+ `columns` is a dictionary that contains the actual content you want to appear in each column. The name of each key at this level must match one of the keys in the `columns` argument. It is agnostic to order, and by leaving a key out, simply nothing will appear in that cell. Existing formatting (cell fill and border) will still apply.
548
+
549
+ The value of each column key may take one of the following forms:
550
+ - string or number → single-line text, prints directly
551
+ - list[str] → multi-line text where the 0th element prints highest within the cell. Use format key `line_spacing` as needed.
552
+ - dict → custom
553
+
554
+ ### Importing a Symbol into a Cell
555
+
556
+ If you add a dictionary to one of the content cells, content start/end groups will be written into your svg. This will allow the user to generate and/or import symbols into the table using their own logic, without regard for placement into the table.
557
+
558
+ ```python
559
+ #from your macro or wherever you're building the table from...
560
+ example_symbol = {
561
+ "lib_repo": instance.get("lib_repo"),
562
+ "item_type": "flagnote",
563
+ "mpn": instance.get("mpn"),
564
+ "instance_name": f"bubble{build_note_number}",
565
+ "note_text": build_note_number,
566
+ }
567
+ symbols_to_build=[example_symbol]
568
+
569
+ svg_utils.table(
570
+ layout_dict,
571
+ format_dict,
572
+ columns_list,
573
+ content_list,
574
+ os.dirname(path_to_table_svg),
575
+ artifact_id
576
+ )
577
+
578
+ # user import logic
579
+ for symbol in symbols_to_build:
580
+ path_to_symbol = #...
581
+ library_utils.pull(
582
+ symbol,
583
+ update_instances_list=False,
584
+ destination_directory=path_to_symbol,
585
+ )
586
+
587
+ svg_utils.find_and_replace_svg_group(
588
+ os.path.join(path_to_symbol, f"{symbol.get('instance_name')}-drawing.svg"),
589
+ symbol.get("instance_name"),
590
+ path_to_table_svg,
591
+ symbol.get("instance_name")
592
+ )
593
+ """
594
+
595
+ # --- Default Style Values (Local to the table() function) ---
596
+ DEFAULTS = {
597
+ "font_size": 12,
598
+ "font_family": "helvetica",
599
+ "font_weight": None,
600
+ "row_height": 0.16 * 96,
601
+ "padding": 3,
602
+ "line_spacing": 14,
603
+ "justify": "center",
604
+ "valign": "middle",
605
+ "fill_color": "white",
606
+ "stroke_color": "black",
607
+ "stroke_width": 1,
608
+ "text_color": "black",
609
+ }
610
+
611
+ # --- Helper functions ---
612
+
613
+ def _validate_layout(layout):
614
+ """Validates the layout dictionary."""
615
+ valid_corners = {"top-left", "top-right", "bottom-left", "bottom-right"}
616
+ valid_directions = {"down", "up"}
617
+ corner = layout.get("origin_corner")
618
+ direction = layout.get("build_direction")
619
+ if corner not in valid_corners:
620
+ raise ValueError(f"Invalid origin_corner: {corner}. Must be one of {valid_corners}.")
621
+ if direction not in valid_directions:
622
+ raise ValueError(f"Invalid build_direction: {direction}. Must be one of {valid_directions}.")
623
+ return layout
624
+
625
+ def _validate_columns(columns_list):
626
+ """Validates the columns list, ensuring 'name' and 'width' are present."""
627
+ if not columns_list:
628
+ raise ValueError("columns_list cannot be empty.")
629
+ names = set()
630
+ for col in columns_list:
631
+ if 'name' not in col or 'width' not in col:
632
+ raise ValueError("Each column must have 'name' and 'width' keys.")
633
+ if col['name'] in names:
634
+ raise ValueError(f"Duplicate column name: {col['name']}.")
635
+ names.add(col['name'])
636
+ return columns_list
637
+
638
+ def _resolve_style(row_format_key, col_data, format_dict, columns_list):
639
+ """Resolves the final style for a cell."""
640
+ style = DEFAULTS.copy()
641
+ style.update(format_dict.get("globals", {}))
642
+ col_name = col_data.get('name')
643
+ column_def = next((c for c in columns_list if c['name'] == col_name), {})
644
+ style.update({k: v for k, v in column_def.items() if k in DEFAULTS})
645
+ row_format = format_dict.get(row_format_key)
646
+ if row_format:
647
+ style.update({k: v for k, v in row_format.items() if k in DEFAULTS})
648
+ return style
649
+
650
+ def _generate_symbol_placeholder(instance_name, bubble_x, bubble_y):
651
+ """Generates the SVG group structure placeholder."""
652
+ svg_lines = []
653
+ svg_lines.append(f'<g id="{instance_name}" transform="translate({bubble_x},{bubble_y})">')
654
+ svg_lines.append(f' <g id="{instance_name}-contents-start">')
655
+ svg_lines.append(" </g>")
656
+ svg_lines.append(f' <g id="{instance_name}-contents-end"/>')
657
+ svg_lines.append("</g>")
658
+ return "\n".join(svg_lines)
659
+
660
+ def _draw_cell_content(x, y, width, height, content, style, columns_list, format_dict):
661
+ """Generates the SVG for the cell's content (text or symbol)."""
662
+ svg_primitives = []
663
+ instances_to_copy_in = []
664
+
665
+ if isinstance(content, dict) and 'instance_name' in content:
666
+ instance_name = content['instance_name']
667
+ item_type = content.get('item_type')
668
+
669
+ align_x = width / 2 if style['justify'] == 'center' else (width - style['padding'] if style['justify'] == 'right' else style['padding'])
670
+ align_y = height / 2 if style['valign'] == 'middle' else (height - style['padding'] if style['valign'] == 'bottom' else style['padding'])
671
+
672
+ symbol_xml = _generate_symbol_placeholder(instance_name=instance_name, bubble_x=x + align_x, bubble_y=y + align_y)
673
+ instances_to_copy_in.append({"item_type":item_type, "instance_name":instance_name})
674
+ svg_primitives.append(symbol_xml)
675
+
676
+ else:
677
+ text_lines = [str(content)] if isinstance(content, (str, int, float)) else [str(line) for line in content] if isinstance(content, list) else []
678
+ if not text_lines:
679
+ return "", []
680
+
681
+ x_pos = x
682
+ text_anchor = "start"
683
+ if style['justify'] == 'center':
684
+ x_pos += width / 2
685
+ text_anchor = "middle"
686
+ elif style['justify'] == 'right':
687
+ x_pos += width - style['padding']
688
+ text_anchor = "end"
689
+ elif style['justify'] == 'left':
690
+ x_pos += style['padding']
691
+ text_anchor = "start"
692
+
693
+ num_lines = len(text_lines)
694
+ total_text_height = (num_lines - 1) * style['line_spacing'] + style['font_size']
695
+ y_start = y
696
+ if style['valign'] == 'middle':
697
+ y_start += (height - total_text_height) / 2
698
+ elif style['valign'] == 'bottom':
699
+ y_start += height - total_text_height - style['padding']
700
+ elif style['valign'] == 'top':
701
+ y_start += style['padding']
702
+ y_pos = y_start + style['font_size'] * 0.8
703
+
704
+ text_style = {"font-size": f"{style['font_size']}px", "font-family": style['font_family'], "fill": style['text_color'], "text-anchor": text_anchor}
705
+ if style['font_weight']:
706
+ if 'B' in style['font_weight']:
707
+ text_style['font-weight'] = 'bold'
708
+ if 'I' in style['font_weight']:
709
+ text_style['font-style'] = 'italic'
710
+ if 'U' in style['font_weight']:
711
+ text_style['text-decoration'] = 'underline'
712
+ style_str = "; ".join(f"{k}: {v}" for k, v in text_style.items())
713
+
714
+ for i, line in enumerate(text_lines):
715
+ current_y = y_pos + i * style['line_spacing']
716
+ svg_primitives.append(f'<text x="{x_pos}" y="{current_y}" style="{style_str}">{line}</text>')
717
+
718
+ return "\n".join(svg_primitives), instances_to_copy_in
719
+
720
+ def _draw_cell_rect(x, y, width, height, style):
721
+ """Generates the SVG for the cell's background and border."""
722
+ rect_style = {"fill": style['fill_color'], "stroke": style['stroke_color'], "stroke-width": style['stroke_width']}
723
+ style_str = "; ".join(f"{k}: {v}" for k, v in rect_style.items())
724
+ return f'<rect x="{x}" y="{y}" width="{width}" height="{height}" style="{style_str}" />'
725
+
726
+ # ====================================================================
727
+ # MAIN FUNCTION LOGIC START
728
+ # ====================================================================
729
+
730
+ # 1. INITIAL VALIDATION (and state definition)
731
+ layout = _validate_layout(layout_dict)
732
+ columns = _validate_columns(columns_list)
733
+ total_width = sum(col['width'] for col in columns)
734
+
735
+ # ... (Setup for BUILD_SVG LOGIC remains unchanged) ...
736
+ svg_rows = []
737
+ col_x_starts = [0]
738
+ current_x = 0
739
+ for col in columns[:-1]:
740
+ current_x += col['width']
741
+ col_x_starts.append(current_x)
742
+
743
+ row_heights = [
744
+ _resolve_style(row.get('format_key'), {}, format_dict, columns).get('row_height', DEFAULTS['row_height'])
745
+ for row in content_list
746
+ ]
747
+ total_table_height = sum(row_heights)
748
+ row_height_0 = row_heights[0]
749
+
750
+ current_y_offset = 0
751
+ instances_to_copy_in = []
752
+
753
+ # 3. BUILD ROW BY ROW
754
+
755
+ # reverse content list if building upwards so the first row stays on top but is now printed last
756
+ if layout['build_direction'] == 'up':
757
+ content_list = list(reversed(content_list))
758
+
759
+ for row_index, row_data in enumerate(content_list):
760
+ row_key = row_data.get('format_key')
761
+ row_height = row_heights[row_index]
762
+ row_svg = []
763
+
764
+ if layout['build_direction'] == 'down':
765
+ cell_y_start = current_y_offset
766
+ current_y_offset += row_height
767
+ elif layout['build_direction'] == 'up':
768
+ current_y_offset -= row_height
769
+ cell_y_start = current_y_offset
770
+
771
+ for col_index, col_def in enumerate(columns):
772
+ col_name = col_def['name']
773
+ col_width = col_def['width']
774
+ cell_content = row_data.get('columns', {}).get(col_name)
775
+ cell_style = _resolve_style(row_key, col_def, format_dict, columns)
776
+ cell_x_start = col_x_starts[col_index]
777
+
778
+ rect_svg = _draw_cell_rect(cell_x_start, cell_y_start, col_width, row_height, cell_style)
779
+ row_svg.append(rect_svg)
780
+
781
+ if cell_content is not None:
782
+ content_svg, instances = _draw_cell_content(
783
+ cell_x_start, cell_y_start, col_width, row_height,
784
+ cell_content, cell_style, columns, format_dict
785
+ )
786
+ row_svg.append(content_svg)
787
+ instances_to_copy_in.extend(instances)
788
+
789
+ svg_rows.append("\n".join(row_svg))
790
+
791
+ # 4. FINAL TRANSFORM CALCULATION
792
+ tx = 0
793
+ ty = 0
794
+
795
+ if layout['origin_corner'] in ('top-right', 'bottom-right'):
796
+ tx = -total_width
797
+
798
+ if layout['build_direction'] == 'down':
799
+ if layout['origin_corner'] in ('bottom-left', 'bottom-right'):
800
+ ty = -row_height_0
801
+ elif layout['build_direction'] == 'up':
802
+ if layout['origin_corner'] in ('top-left', 'top-right'):
803
+ ty = total_table_height
804
+
805
+ table_contents = "\n".join(svg_rows)
806
+
807
+ # 5. WRAP, SAVE, AND INJECT
808
+ group_output = f"""<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000">
809
+ <g id="{contents_group_name}-contents-start">
810
+ <g id="translate" transform="translate({tx}, {ty})">
811
+ {table_contents}
812
+ </g>
813
+ </g>
814
+ <g id="{contents_group_name}-contents-end"/>
815
+ </svg>"""
816
+
817
+ # Save the master SVG file
818
+ with open(path_to_svg, "w") as f:
819
+ f.write(group_output.strip())