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.
- harnice/__init__.py +0 -0
- harnice/__main__.py +4 -0
- harnice/cli.py +234 -0
- harnice/fileio.py +295 -0
- harnice/gui/launcher.py +426 -0
- harnice/lists/channel_map.py +182 -0
- harnice/lists/circuits_list.py +302 -0
- harnice/lists/disconnect_map.py +237 -0
- harnice/lists/formboard_graph.py +63 -0
- harnice/lists/instances_list.py +280 -0
- harnice/lists/library_history.py +40 -0
- harnice/lists/manifest.py +93 -0
- harnice/lists/post_harness_instances_list.py +66 -0
- harnice/lists/rev_history.py +325 -0
- harnice/lists/signals_list.py +135 -0
- harnice/products/__init__.py +1 -0
- harnice/products/cable.py +152 -0
- harnice/products/chtype.py +80 -0
- harnice/products/device.py +844 -0
- harnice/products/disconnect.py +225 -0
- harnice/products/flagnote.py +139 -0
- harnice/products/harness.py +522 -0
- harnice/products/macro.py +10 -0
- harnice/products/part.py +640 -0
- harnice/products/system.py +125 -0
- harnice/products/tblock.py +270 -0
- harnice/state.py +57 -0
- harnice/utils/appearance.py +51 -0
- harnice/utils/circuit_utils.py +326 -0
- harnice/utils/feature_tree_utils.py +183 -0
- harnice/utils/formboard_utils.py +973 -0
- harnice/utils/library_utils.py +333 -0
- harnice/utils/note_utils.py +417 -0
- harnice/utils/svg_utils.py +819 -0
- harnice/utils/system_utils.py +563 -0
- harnice-0.3.0.dist-info/METADATA +32 -0
- harnice-0.3.0.dist-info/RECORD +41 -0
- harnice-0.3.0.dist-info/WHEEL +5 -0
- harnice-0.3.0.dist-info/entry_points.txt +3 -0
- harnice-0.3.0.dist-info/licenses/LICENSE +19 -0
- 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())
|