inksmcp 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.
inksmcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """inksmcp — MCP server that lets AI assistants drive Inkscape."""
2
+
3
+ __version__ = "0.3.0"
inksmcp/checks.py ADDED
@@ -0,0 +1,222 @@
1
+ """Layout checks (D-024 step 2): what agents found by zooming in 6 of 7 round-2 reports.
2
+
3
+ Pure geometry on measured boxes (document coordinates) and line segments; no lxml, no Inkscape.
4
+ Three findings, each for pairs where at least one element was touched by the call:
5
+ - two texts overlap;
6
+ - a text crosses the edge of a rect / circle / ellipse / image (inside it is fine, e.g. a card label);
7
+ - a text without a halo is crossed by a stroked line or outline (a halo means "on purpose").
8
+ A pair is skipped when an opaque shape painted between them covers the text: what lies under a balloon or
9
+ caption box can't collide with the text on it (E29).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ Box = tuple[float, float, float, float]
16
+ Seg = tuple[tuple[float, float], tuple[float, float]]
17
+ MAX_WARNINGS = 12
18
+
19
+ _TOKEN = re.compile(r"[A-Za-z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
20
+
21
+
22
+ def path_segments(d: str) -> list[Seg]:
23
+ """Straight segments of a path, subpaths kept apart. Curves raise ValueError (not checked)."""
24
+ segs: list[Seg] = []
25
+ x = y = sx = sy = 0.0
26
+ cmd = None
27
+ tokens = _TOKEN.findall(d or "")
28
+ i = 0
29
+ while i < len(tokens):
30
+ t = tokens[i]
31
+ if t.isalpha():
32
+ cmd = t
33
+ i += 1
34
+ if cmd in "Zz":
35
+ if (x, y) != (sx, sy):
36
+ segs.append(((x, y), (sx, sy)))
37
+ x, y = sx, sy
38
+ elif cmd not in "MmLlHhVv":
39
+ raise ValueError(f"curve command {cmd!r}")
40
+ continue
41
+ need = 1 if cmd in "HhVv" else 2
42
+ n = [float(v) for v in tokens[i:i + need]]
43
+ i += need
44
+ px, py = x, y
45
+ if cmd in "Mm":
46
+ x, y = (x + n[0], y + n[1]) if cmd == "m" else (n[0], n[1])
47
+ sx, sy = x, y
48
+ cmd = "l" if cmd == "m" else "L"
49
+ continue
50
+ if cmd == "L":
51
+ x, y = n
52
+ elif cmd == "l":
53
+ x, y = x + n[0], y + n[1]
54
+ elif cmd == "H":
55
+ x = n[0]
56
+ elif cmd == "h":
57
+ x += n[0]
58
+ elif cmd == "V":
59
+ y = n[0]
60
+ elif cmd == "v":
61
+ y += n[0]
62
+ segs.append(((px, py), (x, y)))
63
+ return segs
64
+
65
+
66
+ def intersection(a: Box, b: Box) -> tuple[float, float]:
67
+ """Width and height of the overlap of two boxes (<= 0 when apart)."""
68
+ return (min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]), min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
69
+
70
+
71
+ def inside(a: Box, b: Box, tol: float) -> bool:
72
+ """a lies within b."""
73
+ return (a[0] >= b[0] - tol and a[1] >= b[1] - tol and a[0] + a[2] <= b[0] + b[2] + tol
74
+ and a[1] + a[3] <= b[1] + b[3] + tol)
75
+
76
+
77
+ def segment_hits(p, q, box: Box, shrink: float) -> bool:
78
+ """Does segment p-q pass through the box (shrunk by `shrink` on each side)? Liang–Barsky."""
79
+ x0, y0 = box[0] + shrink, box[1] + shrink
80
+ x1, y1 = box[0] + box[2] - shrink, box[1] + box[3] - shrink
81
+ if x1 <= x0 or y1 <= y0:
82
+ return False
83
+ dx, dy = q[0] - p[0], q[1] - p[1]
84
+ t0, t1 = 0.0, 1.0
85
+ for pk, qk in ((-dx, p[0] - x0), (dx, x1 - p[0]), (-dy, p[1] - y0), (dy, y1 - p[1])):
86
+ if pk == 0:
87
+ if qk < 0:
88
+ return False
89
+ continue
90
+ r = qk / pk
91
+ if pk < 0:
92
+ t0 = max(t0, r)
93
+ else:
94
+ t1 = min(t1, r)
95
+ if t0 > t1:
96
+ return False
97
+ return True
98
+
99
+
100
+ def covers(shape: tuple, box: Box) -> bool:
101
+ """Does an opaque shape cover the whole box? shape = (kind, local geometry, inverse CTM); the box is in
102
+ document coordinates. kind: rect (x, y, w, h, rx, ry), ellipse (cx, cy, rx, ry), polygon (points)."""
103
+ kind, g, inv = shape
104
+ a, b, c, d, e, f = inv
105
+ pts = [(a * x + c * y + e, b * x + d * y + f)
106
+ for x, y in ((box[0], box[1]), (box[0] + box[2], box[1]), (box[0], box[1] + box[3]),
107
+ (box[0] + box[2], box[1] + box[3]))]
108
+ if kind == "rect":
109
+ x0, y0, w, h, rx, ry = g
110
+ for px, py in pts:
111
+ if not (x0 <= px <= x0 + w and y0 <= py <= y0 + h):
112
+ return False
113
+ if rx > 0 and ry > 0: # in a rounded corner's square: inside the arc?
114
+ cx = x0 + rx if px < x0 + rx else x0 + w - rx if px > x0 + w - rx else None
115
+ cy = y0 + ry if py < y0 + ry else y0 + h - ry if py > y0 + h - ry else None
116
+ if cx is not None and cy is not None and ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2 > 1:
117
+ return False
118
+ return True
119
+ if kind == "ellipse": # convex: the corners inside means the box inside
120
+ cx, cy, rx, ry = g
121
+ return rx > 0 and ry > 0 and all(((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2 <= 1 for px, py in pts)
122
+ poly = g # even-odd point test on the corners, and no edge through the box (non-convex outlines)
123
+ for px, py in pts:
124
+ hit = False
125
+ for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
126
+ if (y1 > py) != (y2 > py) and px < x1 + (py - y1) * (x2 - x1) / (y2 - y1):
127
+ hit = not hit
128
+ if not hit:
129
+ return False
130
+ lx, ly = min(p[0] for p in pts), min(p[1] for p in pts)
131
+ local = (lx, ly, max(p[0] for p in pts) - lx, max(p[1] for p in pts) - ly)
132
+ return not any(segment_hits(p, q, local, 0) for p, q in zip(poly, poly[1:] + poly[:1]))
133
+
134
+
135
+ def _grouped(found: list[tuple[str, str]], one: str, many: str) -> list[str]:
136
+ """One line per text: a pattern behind an uncovered text is one finding, not twenty."""
137
+ by: dict[str, list[str]] = {}
138
+ for t, s in found:
139
+ by.setdefault(t, []).append(s)
140
+ out = []
141
+ for t, ss in by.items():
142
+ if len(ss) == 1:
143
+ out.append(one.format(t=t, s=ss[0]))
144
+ else:
145
+ names = ", ".join(repr(s) for s in ss[:3]) + (f" (+{len(ss) - 3} more)" if len(ss) > 3 else "")
146
+ out.append(many.format(t=t, n=len(ss), s=names))
147
+ return out
148
+
149
+
150
+ def stacked(boxes: dict[str, Box], points: dict[str, tuple[float, float]], tol: float) -> set[str]:
151
+ """Elements dropped on one spot to be arranged later (the workflow the server recommends): three or
152
+ more sharing a box top-left corner, a box centre, or (texts) an anchor point. Their overlaps are
153
+ expected, not findings."""
154
+ groups: dict[tuple, set[str]] = {}
155
+ q = max(tol, 1e-6)
156
+ for i, (x, y, w, h) in boxes.items():
157
+ keys = [("tl", round(x / q), round(y / q)), ("c", round((x + w / 2) / q), round((y + h / 2) / q))]
158
+ if i in points:
159
+ keys.append(("a", round(points[i][0] / q), round(points[i][1] / q)))
160
+ for key in keys:
161
+ groups.setdefault(key, set()).add(i)
162
+ return set().union(*(g for g in groups.values() if len(g) >= 3)) if groups else set()
163
+
164
+
165
+ def find(texts: dict[str, Box], areas: dict[str, Box], lines: dict[str, tuple[Box, list[Seg], float]],
166
+ touched: set[str], haloed: set[str], skip_pairs: set[frozenset], tol: float,
167
+ anchors: dict[str, tuple[float, float]] | None = None, order: dict[str, int] | None = None,
168
+ cover: dict[str, int] | None = None) -> list[str]:
169
+ """Warnings, most useful first; at most MAX_WARNINGS plus a count of the rest. `anchors`: the
170
+ x/y points of texts (their glyph boxes differ, their anchors match when they are stacked).
171
+ `order`: paint order; `cover[t]`: the paint order of the topmost opaque shape covering text t."""
172
+ pile = stacked({**texts, **areas, **{k: v[0] for k, v in lines.items()}}, anchors or {}, tol)
173
+ order, cover = order or {}, cover or {}
174
+
175
+ def hidden(t: str, other: str) -> bool: # `other` is painted under an opaque shape that covers t
176
+ return t in cover and order.get(other, cover[t]) < cover[t]
177
+
178
+ def wanted(a: str, b: str) -> bool:
179
+ return ((a in touched or b in touched) and frozenset((a, b)) not in skip_pairs
180
+ and not (a in pile and b in pile) and not hidden(a, b) and not hidden(b, a))
181
+
182
+ out: list[str] = []
183
+ tids = sorted(texts)
184
+ for n, a in enumerate(tids):
185
+ for b in tids[n + 1:]:
186
+ if wanted(a, b):
187
+ w, h = intersection(texts[a], texts[b])
188
+ if w > tol and h > tol:
189
+ out.append(f"text {a!r} overlaps text {b!r} ({w:.1f} x {h:.1f}).")
190
+ edges = []
191
+ top_first = sorted(areas, key=lambda s: -order.get(s, 0)) # a text's own box before what lies under it
192
+ for t in tids:
193
+ for s in top_first:
194
+ box = areas[s]
195
+ if wanted(t, s):
196
+ w, h = intersection(texts[t], box)
197
+ if w > tol and h > tol and not inside(texts[t], box, tol) and not inside(box, texts[t], tol):
198
+ edges.append((t, s))
199
+ out += _grouped(edges, "text {t!r} crosses the edge of {s!r}.", "text {t!r} crosses the edges of {n} shapes: {s}.")
200
+ crossed = []
201
+ for t in tids:
202
+ if t in haloed:
203
+ continue
204
+ for s in sorted(lines, key=lambda s: -order.get(s, 0)):
205
+ box, segs, width = lines[s]
206
+ if not wanted(t, s):
207
+ continue
208
+ if width >= texts[t][3] and inside(texts[t], box, tol):
209
+ continue # a label ON a thick stroke (a bar's value), not crossed by it
210
+ w, h = intersection(texts[t], box)
211
+ if w > -tol and h > -tol and any(segment_hits(p, q, texts[t], tol) for p, q in segs):
212
+ crossed.append((t, s))
213
+ out += _grouped(crossed, "text {t!r} is crossed by {s!r} (give it a halo if that is intended).",
214
+ "text {t!r} is crossed by {n} lines: {s} (give it a halo if that is intended).")
215
+ if len(out) > MAX_WARNINGS:
216
+ out = out[:MAX_WARNINGS] + [f"... and {len(out) - MAX_WARNINGS} more overlaps."]
217
+ piled = sorted(pile & touched)
218
+ if piled:
219
+ more = f" (+{len(piled) - 4})" if len(piled) > 4 else ""
220
+ out.append(f"{len(piled)} elements are stacked on one spot ({', '.join(piled[:4])}{more}): "
221
+ "not checked against each other until arranged.")
222
+ return out