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 +3 -0
- inksmcp/checks.py +222 -0
- inksmcp/document.py +1429 -0
- inksmcp/engine.py +1403 -0
- inksmcp/files.py +247 -0
- inksmcp/grids.py +152 -0
- inksmcp/inkscape.py +249 -0
- inksmcp/layout.py +372 -0
- inksmcp/server.py +765 -0
- inksmcp/templates.py +152 -0
- inksmcp-0.3.0.dist-info/METADATA +138 -0
- inksmcp-0.3.0.dist-info/RECORD +15 -0
- inksmcp-0.3.0.dist-info/WHEEL +4 -0
- inksmcp-0.3.0.dist-info/entry_points.txt +2 -0
- inksmcp-0.3.0.dist-info/licenses/LICENSE +21 -0
inksmcp/__init__.py
ADDED
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
|