codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/lint_geom.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""v0.12.0: geometric text checks over the *drawn* SVG (lint rule CF-TXT-010).
|
|
2
|
+
|
|
3
|
+
The scene-graph overlap check (CF-TXT-005) only sees nodes. Many labels are drawn by
|
|
4
|
+
their owner instead: group titles, region labels, Matrix side labels, Fork branch
|
|
5
|
+
labels, span labels, chart tick labels. This module looks at what was actually
|
|
6
|
+
painted, in paint order:
|
|
7
|
+
|
|
8
|
+
* **covered** — an opaque fill painted *after* a text run hides part of it (a group
|
|
9
|
+
title under its first child, a region label under a neighbouring box, a Matrix
|
|
10
|
+
label clipped by the next box, a label under an arrowhead);
|
|
11
|
+
* **struck** — a stroke (edge, frame, region outline, span line, marker) runs through
|
|
12
|
+
the middle band of a text run; **grazes** when it only touches the glyph box;
|
|
13
|
+
* **text–text** — two text runs overlap.
|
|
14
|
+
|
|
15
|
+
Knockout holes (edge labels) are honoured: a stroke clipped away around a label does
|
|
16
|
+
not count.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
Pt = Tuple[float, float]
|
|
25
|
+
Rect = Tuple[float, float, float, float] # x0, y0, x1, y1
|
|
26
|
+
|
|
27
|
+
_NUM = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply(m: Sequence[float], x: float, y: float) -> Pt:
|
|
31
|
+
return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _f(v: Any, d: float = 0.0) -> float:
|
|
35
|
+
try:
|
|
36
|
+
return float(re.sub(r"[a-z%]+$", "", str(v)))
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_path(d: str) -> List[Tuple[List[Pt], bool]]:
|
|
42
|
+
"""Flatten an SVG path into polylines ``[(points, closed)]`` (curves sampled)."""
|
|
43
|
+
toks = re.findall(r"[MmLlHhVvCcSsQqTtAaZz]|" + _NUM, d or "")
|
|
44
|
+
out: List[Tuple[List[Pt], bool]] = []
|
|
45
|
+
cur: List[Pt] = []
|
|
46
|
+
x = y = 0.0
|
|
47
|
+
sx = sy = 0.0
|
|
48
|
+
i = 0
|
|
49
|
+
cmd = ""
|
|
50
|
+
last_ctrl: Optional[Pt] = None
|
|
51
|
+
|
|
52
|
+
def num() -> float:
|
|
53
|
+
nonlocal i
|
|
54
|
+
v = float(toks[i])
|
|
55
|
+
i += 1
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
def flush(closed: bool = False) -> None:
|
|
59
|
+
nonlocal cur
|
|
60
|
+
if len(cur) >= 2:
|
|
61
|
+
out.append((cur, closed))
|
|
62
|
+
cur = []
|
|
63
|
+
|
|
64
|
+
while i < len(toks):
|
|
65
|
+
t = toks[i]
|
|
66
|
+
if re.match(r"[A-Za-z]", t):
|
|
67
|
+
cmd = t
|
|
68
|
+
i += 1
|
|
69
|
+
if cmd in "Zz":
|
|
70
|
+
if cur:
|
|
71
|
+
cur.append((sx, sy))
|
|
72
|
+
flush(True)
|
|
73
|
+
x, y = sx, sy
|
|
74
|
+
continue
|
|
75
|
+
if not cmd:
|
|
76
|
+
i += 1
|
|
77
|
+
continue
|
|
78
|
+
rel = cmd.islower()
|
|
79
|
+
c = cmd.upper()
|
|
80
|
+
try:
|
|
81
|
+
if c == "M":
|
|
82
|
+
nx, ny = num(), num()
|
|
83
|
+
if rel:
|
|
84
|
+
nx, ny = x + nx, y + ny
|
|
85
|
+
flush()
|
|
86
|
+
x, y = nx, ny
|
|
87
|
+
sx, sy = x, y
|
|
88
|
+
cur = [(x, y)]
|
|
89
|
+
cmd = "l" if rel else "L"
|
|
90
|
+
elif c == "L":
|
|
91
|
+
nx, ny = num(), num()
|
|
92
|
+
x, y = (x + nx, y + ny) if rel else (nx, ny)
|
|
93
|
+
cur.append((x, y))
|
|
94
|
+
elif c == "H":
|
|
95
|
+
nx = num()
|
|
96
|
+
x = x + nx if rel else nx
|
|
97
|
+
cur.append((x, y))
|
|
98
|
+
elif c == "V":
|
|
99
|
+
ny = num()
|
|
100
|
+
y = y + ny if rel else ny
|
|
101
|
+
cur.append((x, y))
|
|
102
|
+
elif c in "CSQT":
|
|
103
|
+
if c == "C":
|
|
104
|
+
pts = [num() for _ in range(6)]
|
|
105
|
+
elif c == "S":
|
|
106
|
+
pts = [0.0, 0.0] + [num() for _ in range(4)]
|
|
107
|
+
elif c == "Q":
|
|
108
|
+
pts = [num() for _ in range(4)]
|
|
109
|
+
else:
|
|
110
|
+
pts = [num(), num()]
|
|
111
|
+
if rel:
|
|
112
|
+
pts = [v + (x if k % 2 == 0 else y) for k, v in enumerate(pts)]
|
|
113
|
+
if c == "S":
|
|
114
|
+
lc = last_ctrl or (x, y)
|
|
115
|
+
pts[0], pts[1] = 2 * x - lc[0], 2 * y - lc[1]
|
|
116
|
+
if c in "CS":
|
|
117
|
+
p0, p1, p2, p3 = (x, y), (pts[0], pts[1]), (pts[2], pts[3]), (pts[4], pts[5])
|
|
118
|
+
for k in range(1, 9):
|
|
119
|
+
u = k / 8
|
|
120
|
+
a, b, cc, dd = (1 - u) ** 3, 3 * u * (1 - u) ** 2, 3 * u * u * (1 - u), u ** 3
|
|
121
|
+
cur.append((a * p0[0] + b * p1[0] + cc * p2[0] + dd * p3[0],
|
|
122
|
+
a * p0[1] + b * p1[1] + cc * p2[1] + dd * p3[1]))
|
|
123
|
+
last_ctrl = p2
|
|
124
|
+
x, y = p3
|
|
125
|
+
elif c == "Q":
|
|
126
|
+
p0, p1, p2 = (x, y), (pts[0], pts[1]), (pts[2], pts[3])
|
|
127
|
+
for k in range(1, 7):
|
|
128
|
+
u = k / 6
|
|
129
|
+
cur.append(((1 - u) ** 2 * p0[0] + 2 * u * (1 - u) * p1[0] + u * u * p2[0],
|
|
130
|
+
(1 - u) ** 2 * p0[1] + 2 * u * (1 - u) * p1[1] + u * u * p2[1]))
|
|
131
|
+
x, y = p2
|
|
132
|
+
else:
|
|
133
|
+
x, y = pts[0], pts[1]
|
|
134
|
+
cur.append((x, y))
|
|
135
|
+
continue
|
|
136
|
+
elif c == "A":
|
|
137
|
+
rx, ry, _rot, large, sweep, nx, ny = [num() for _ in range(7)]
|
|
138
|
+
if rel:
|
|
139
|
+
nx, ny = x + nx, y + ny
|
|
140
|
+
cur.extend(_arc_points(x, y, rx, ry, _rot, bool(large), bool(sweep), nx, ny))
|
|
141
|
+
x, y = nx, ny
|
|
142
|
+
else:
|
|
143
|
+
i += 1
|
|
144
|
+
except (IndexError, ValueError):
|
|
145
|
+
break
|
|
146
|
+
last_ctrl = None
|
|
147
|
+
flush()
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float, fa: bool,
|
|
152
|
+
fs: bool, x2: float, y2: float, n: int = 12) -> List[Pt]:
|
|
153
|
+
"""Sample an SVG elliptical arc (endpoint parameterisation, SVG spec F.6.5)."""
|
|
154
|
+
if rx == 0 or ry == 0 or (x1 == x2 and y1 == y2):
|
|
155
|
+
return [(x2, y2)]
|
|
156
|
+
rx, ry = abs(rx), abs(ry)
|
|
157
|
+
phi = math.radians(phi_deg)
|
|
158
|
+
c, s_ = math.cos(phi), math.sin(phi)
|
|
159
|
+
dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
|
|
160
|
+
x1p, y1p = c * dx + s_ * dy, -s_ * dx + c * dy
|
|
161
|
+
lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
|
|
162
|
+
if lam > 1:
|
|
163
|
+
rx, ry = rx * math.sqrt(lam), ry * math.sqrt(lam)
|
|
164
|
+
num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
|
165
|
+
den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
|
166
|
+
co = math.sqrt(max(0.0, num / den)) if den else 0.0
|
|
167
|
+
if fa == fs:
|
|
168
|
+
co = -co
|
|
169
|
+
cxp, cyp = co * rx * y1p / ry, -co * ry * x1p / rx
|
|
170
|
+
cx, cy = c * cxp - s_ * cyp + (x1 + x2) / 2, s_ * cxp + c * cyp + (y1 + y2) / 2
|
|
171
|
+
|
|
172
|
+
def ang(ux: float, uy: float, vx: float, vy: float) -> float:
|
|
173
|
+
a = math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
|
|
174
|
+
return a
|
|
175
|
+
|
|
176
|
+
t1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
|
|
177
|
+
dt = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry)
|
|
178
|
+
if not fs and dt > 0:
|
|
179
|
+
dt -= 2 * math.pi
|
|
180
|
+
elif fs and dt < 0:
|
|
181
|
+
dt += 2 * math.pi
|
|
182
|
+
out = []
|
|
183
|
+
for k in range(1, n + 1):
|
|
184
|
+
t = t1 + dt * k / n
|
|
185
|
+
ex, ey = rx * math.cos(t), ry * math.sin(t)
|
|
186
|
+
out.append((c * ex - s_ * ey + cx, s_ * ex + c * ey + cy))
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_none(c: Optional[str]) -> bool:
|
|
191
|
+
return c is None or str(c).strip().lower() in ("none", "transparent", "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _opaque_fill(a: Dict[str, str]) -> bool:
|
|
195
|
+
f = a.get("fill")
|
|
196
|
+
if _is_none(f) or str(f).startswith("url("):
|
|
197
|
+
return False
|
|
198
|
+
if _f(a.get("fill-opacity", 1), 1) < 0.5 or _f(a.get("opacity", 1), 1) < 0.5:
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _stroked(a: Dict[str, str]) -> bool:
|
|
204
|
+
if _is_none(a.get("stroke")):
|
|
205
|
+
return False
|
|
206
|
+
if _f(a.get("stroke-width", 1), 1) <= 0:
|
|
207
|
+
return False
|
|
208
|
+
if _f(a.get("stroke-opacity", 1), 1) < 0.25 or _f(a.get("opacity", 1), 1) < 0.25:
|
|
209
|
+
return False
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _geometry(tag: str, a: Dict[str, str]) -> List[Tuple[List[Pt], bool]]:
|
|
214
|
+
if tag == "line":
|
|
215
|
+
return [([(_f(a.get("x1")), _f(a.get("y1"))), (_f(a.get("x2")), _f(a.get("y2")))], False)]
|
|
216
|
+
if tag == "rect":
|
|
217
|
+
x, y, w, h = _f(a.get("x")), _f(a.get("y")), _f(a.get("width")), _f(a.get("height"))
|
|
218
|
+
if w <= 0 or h <= 0:
|
|
219
|
+
return []
|
|
220
|
+
return [([(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)], True)]
|
|
221
|
+
if tag in ("polyline", "polygon"):
|
|
222
|
+
nums = [float(v) for v in re.findall(_NUM, a.get("points", ""))]
|
|
223
|
+
pts = list(zip(nums[0::2], nums[1::2]))
|
|
224
|
+
if tag == "polygon" and pts:
|
|
225
|
+
pts.append(pts[0])
|
|
226
|
+
return [(pts, tag == "polygon")] if len(pts) >= 2 else []
|
|
227
|
+
if tag in ("circle", "ellipse"):
|
|
228
|
+
cx, cy = _f(a.get("cx")), _f(a.get("cy"))
|
|
229
|
+
rx = _f(a.get("r")) if tag == "circle" else _f(a.get("rx"))
|
|
230
|
+
ry = _f(a.get("r")) if tag == "circle" else _f(a.get("ry"))
|
|
231
|
+
pts = [(cx + rx * math.cos(2 * math.pi * k / 24), cy + ry * math.sin(2 * math.pi * k / 24))
|
|
232
|
+
for k in range(25)]
|
|
233
|
+
return [(pts, True)]
|
|
234
|
+
if tag == "path":
|
|
235
|
+
return parse_path(a.get("d", ""))
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _clip_holes(svg: str) -> Dict[str, List[Rect]]:
|
|
240
|
+
"""Knockout clipPaths (edges.knockout_clip): id → hole rects (user space)."""
|
|
241
|
+
out: Dict[str, List[Rect]] = {}
|
|
242
|
+
for cid, d in re.findall(r'<clipPath id="([^"]+)"><path d="([^"]*)"', svg):
|
|
243
|
+
holes = []
|
|
244
|
+
for x, y, y2, x2, _y in re.findall(
|
|
245
|
+
r"M (%s),(%s) V (%s) H (%s) V (%s) Z" % (_NUM, _NUM, _NUM, _NUM, _NUM), d):
|
|
246
|
+
x, y, y2, x2 = float(x), float(y), float(y2), float(x2)
|
|
247
|
+
holes.append((min(x, x2), min(y, y2), max(x, x2), max(y, y2)))
|
|
248
|
+
out[cid] = holes
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _clip_seg(p: Pt, q: Pt, r: Rect) -> Optional[Tuple[Pt, Pt]]:
|
|
253
|
+
x0, y0, x1, y1 = r
|
|
254
|
+
dx, dy = q[0] - p[0], q[1] - p[1]
|
|
255
|
+
t0, t1 = 0.0, 1.0
|
|
256
|
+
for pp, qq in ((-dx, p[0] - x0), (dx, x1 - p[0]), (-dy, p[1] - y0), (dy, y1 - p[1])):
|
|
257
|
+
if abs(pp) < 1e-12:
|
|
258
|
+
if qq < 0:
|
|
259
|
+
return None
|
|
260
|
+
continue
|
|
261
|
+
t = qq / pp
|
|
262
|
+
if pp < 0:
|
|
263
|
+
t0 = max(t0, t)
|
|
264
|
+
else:
|
|
265
|
+
t1 = min(t1, t)
|
|
266
|
+
if t0 > t1:
|
|
267
|
+
return None
|
|
268
|
+
return ((p[0] + dx * t0, p[1] + dy * t0), (p[0] + dx * t1, p[1] + dy * t1))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _in(r: Rect, x: float, y: float) -> bool:
|
|
272
|
+
return r[0] <= x <= r[2] and r[1] <= y <= r[3]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _inter(a: Rect, b: Rect) -> float:
|
|
276
|
+
w = min(a[2], b[2]) - max(a[0], b[0])
|
|
277
|
+
h = min(a[3], b[3]) - max(a[1], b[1])
|
|
278
|
+
return w * h if w > 0 and h > 0 else 0.0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _area(r: Rect) -> float:
|
|
282
|
+
return max(0.0, r[2] - r[0]) * max(0.0, r[3] - r[1])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _pip(poly: Sequence[Pt], x: float, y: float) -> bool:
|
|
286
|
+
inside = False
|
|
287
|
+
n = len(poly)
|
|
288
|
+
j = n - 1
|
|
289
|
+
for i in range(n):
|
|
290
|
+
xi, yi = poly[i]
|
|
291
|
+
xj, yj = poly[j]
|
|
292
|
+
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi:
|
|
293
|
+
inside = not inside
|
|
294
|
+
j = i
|
|
295
|
+
return inside
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class _TextRun:
|
|
299
|
+
__slots__ = ("p", "idx", "core", "band", "text", "fs")
|
|
300
|
+
|
|
301
|
+
def __init__(self, p: Any, idx: int, core: Rect, band: Rect, fs: float) -> None:
|
|
302
|
+
self.p, self.idx, self.core, self.band, self.text, self.fs = p, idx, core, band, p.text, fs
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _aabb(m: Sequence[float], x0: float, y0: float, x1: float, y1: float) -> Rect:
|
|
306
|
+
pts = [_apply(m, x0, y0), _apply(m, x1, y0), _apply(m, x0, y1), _apply(m, x1, y1)]
|
|
307
|
+
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
|
|
308
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def text_runs(prims: Sequence[Any]) -> List[_TextRun]:
|
|
312
|
+
from .textutil import measure_text
|
|
313
|
+
|
|
314
|
+
out: List[_TextRun] = []
|
|
315
|
+
cursor: Dict[int, float] = {}
|
|
316
|
+
for idx, p in enumerate(prims):
|
|
317
|
+
if p.tag != "text" or p.in_defs or p.font_size <= 0 or not (p.text or "").strip():
|
|
318
|
+
continue
|
|
319
|
+
a = p.attrs
|
|
320
|
+
fs = p.font_size
|
|
321
|
+
mono = "mono" in (p.family or "").lower()
|
|
322
|
+
bold = str(p.weight) in ("bold", "600", "700", "800", "900")
|
|
323
|
+
italic = a.get("font-style") == "italic"
|
|
324
|
+
try:
|
|
325
|
+
w = measure_text(p.text, fs, mono=mono, weight="bold" if bold else "normal",
|
|
326
|
+
italic=italic)[0]
|
|
327
|
+
except Exception: # pragma: no cover
|
|
328
|
+
continue
|
|
329
|
+
if "x" in a:
|
|
330
|
+
x = _f(a.get("x"))
|
|
331
|
+
elif p.text_id in cursor:
|
|
332
|
+
x = cursor[p.text_id]
|
|
333
|
+
else:
|
|
334
|
+
continue
|
|
335
|
+
anchor = a.get("text-anchor", "start")
|
|
336
|
+
if anchor == "middle":
|
|
337
|
+
x -= w / 2
|
|
338
|
+
elif anchor == "end":
|
|
339
|
+
x -= w
|
|
340
|
+
cursor[p.text_id] = x + w
|
|
341
|
+
y = _f(a.get("y"))
|
|
342
|
+
bs = str(a.get("baseline-shift", ""))
|
|
343
|
+
if bs.startswith("-"):
|
|
344
|
+
y += fs * 0.35
|
|
345
|
+
elif bs and bs not in ("0", "baseline"):
|
|
346
|
+
y -= fs * 0.45
|
|
347
|
+
ins = 0.06 * fs
|
|
348
|
+
core = _aabb(p.m, x + ins, y - 0.66 * fs, x + w - ins, y - 0.06 * fs)
|
|
349
|
+
band = _aabb(p.m, x + 2 * ins, y - 0.52 * fs, x + w - 2 * ins, y - 0.2 * fs)
|
|
350
|
+
out.append(_TextRun(p, idx, core, band, fs * p.scale))
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _related(a: Any, b: Any) -> bool:
|
|
355
|
+
"""True when node ``b`` is ``a`` or one of its ancestors / descendants."""
|
|
356
|
+
if a is None or b is None:
|
|
357
|
+
return False
|
|
358
|
+
n = a
|
|
359
|
+
while n is not None:
|
|
360
|
+
if n is b:
|
|
361
|
+
return True
|
|
362
|
+
n = getattr(n, "parent", None)
|
|
363
|
+
n = b
|
|
364
|
+
while n is not None:
|
|
365
|
+
if n is a:
|
|
366
|
+
return True
|
|
367
|
+
n = getattr(n, "parent", None)
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _edge_inside(edge: Any, owner: Any) -> bool:
|
|
372
|
+
"""The text belongs to one of the edge's ends: a container title / caption the edge
|
|
373
|
+
has to pass on its way in or out (``owner`` contains an end node, or ``owner`` is a
|
|
374
|
+
caption sitting next to the end node or its group), not a crossing."""
|
|
375
|
+
op = getattr(owner, "parent", None)
|
|
376
|
+
for port in (getattr(edge, "src", None), getattr(edge, "dst", None)):
|
|
377
|
+
n = getattr(port, "node", None)
|
|
378
|
+
depth = 0
|
|
379
|
+
while n is not None:
|
|
380
|
+
if n is owner:
|
|
381
|
+
return True
|
|
382
|
+
if op is not None and depth in (1, 2) and n is op:
|
|
383
|
+
return True
|
|
384
|
+
n = getattr(n, "parent", None)
|
|
385
|
+
depth += 1
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def check_text_geometry(prims: Sequence[Any], svg: str, ignored: Any) -> Dict[str, list]:
|
|
390
|
+
"""Return ``{"covered": [...], "struck": [...], "grazed": [...], "text": [...]}``;
|
|
391
|
+
each item ``(text_run, other_prim_or_run, rect)``."""
|
|
392
|
+
runs = text_runs(prims)
|
|
393
|
+
res: Dict[str, list] = {"covered": [], "struck": [], "grazed": [], "text": [], "gapped": []}
|
|
394
|
+
if not runs:
|
|
395
|
+
return res
|
|
396
|
+
holes = _clip_holes(svg)
|
|
397
|
+
shapes = [] # (idx, prim, polylines in root coords, aabb, fill?, stroke?)
|
|
398
|
+
for idx, p in enumerate(prims):
|
|
399
|
+
if p.tag == "text" or p.in_defs or p.tag == "image":
|
|
400
|
+
continue
|
|
401
|
+
if p.clip.startswith("cf-hatch"): # hatch texture inside a shape, not a line
|
|
402
|
+
continue
|
|
403
|
+
a = p.attrs
|
|
404
|
+
fill, stroke = _opaque_fill(a), _stroked(a)
|
|
405
|
+
if not fill and not stroke:
|
|
406
|
+
continue
|
|
407
|
+
geo = _geometry(p.tag, a)
|
|
408
|
+
if not geo:
|
|
409
|
+
continue
|
|
410
|
+
polys = [([_apply(p.m, x, y) for x, y in pts], closed) for pts, closed in geo]
|
|
411
|
+
xs = [x for pts, _ in polys for x, _y in pts]
|
|
412
|
+
ys = [y for pts, _ in polys for _x, y in pts]
|
|
413
|
+
if not xs:
|
|
414
|
+
continue
|
|
415
|
+
hs = []
|
|
416
|
+
if p.clip and p.clip in holes:
|
|
417
|
+
hs = [_aabb(p.m, *h) for h in holes[p.clip]]
|
|
418
|
+
shapes.append((idx, p, polys, (min(xs), min(ys), max(xs), max(ys)), fill, stroke, hs))
|
|
419
|
+
|
|
420
|
+
own_bb: Dict[int, Rect] = {}
|
|
421
|
+
for _i, p, _polys, bb, _f_, _s_, _h in shapes:
|
|
422
|
+
if p.owner is None:
|
|
423
|
+
continue
|
|
424
|
+
o = own_bb.get(id(p.owner))
|
|
425
|
+
own_bb[id(p.owner)] = bb if o is None else (min(o[0], bb[0]), min(o[1], bb[1]),
|
|
426
|
+
max(o[2], bb[2]), max(o[3], bb[3]))
|
|
427
|
+
for r in runs:
|
|
428
|
+
if ignored(r.p.chain, "CF-TXT-010"):
|
|
429
|
+
continue
|
|
430
|
+
core = r.core
|
|
431
|
+
ca = _area(core)
|
|
432
|
+
if ca <= 0:
|
|
433
|
+
continue
|
|
434
|
+
owner = r.p.owner
|
|
435
|
+
covered_by = None
|
|
436
|
+
struck_by = None
|
|
437
|
+
grazed_by = None
|
|
438
|
+
gapped_by = None
|
|
439
|
+
near = [(idx, polys[0][0], bb) for idx, p, polys, bb, fill, stroke, hs in shapes
|
|
440
|
+
if fill and polys and polys[0][1] and bb[0] <= core[2] and bb[2] >= core[0]
|
|
441
|
+
and bb[1] <= core[3] and bb[3] >= core[1]]
|
|
442
|
+
|
|
443
|
+
def hidden(x: float, y: float, after: int) -> bool:
|
|
444
|
+
"""Point painted over by an opaque fill drawn after index ``after``."""
|
|
445
|
+
for fi, poly, fb in near:
|
|
446
|
+
if fi > after and _in(fb, x, y) and _pip(poly, x, y):
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
# a label overflowing its own node is CF-TXT-007's finding, not ours
|
|
451
|
+
ob = own_bb.get(id(owner))
|
|
452
|
+
overflow = ob is not None and (core[0] < ob[0] - 0.5 or core[2] > ob[2] + 0.5)
|
|
453
|
+
for idx, p, polys, bb, fill, stroke, hs in shapes:
|
|
454
|
+
if overflow and p.owner is owner:
|
|
455
|
+
continue
|
|
456
|
+
if _inter(bb, core) <= 0 and not (
|
|
457
|
+
bb[0] <= core[2] and bb[2] >= core[0] and bb[1] <= core[3] and bb[3] >= core[1]):
|
|
458
|
+
continue
|
|
459
|
+
if fill and idx > r.idx and covered_by is None and not _related(owner, p.owner) \
|
|
460
|
+
and polys and polys[0][1]:
|
|
461
|
+
poly = polys[0][0]
|
|
462
|
+
n_in = n = 0
|
|
463
|
+
for gx in range(7):
|
|
464
|
+
for gy in range(3):
|
|
465
|
+
x = core[0] + (core[2] - core[0]) * (gx + 0.5) / 7
|
|
466
|
+
y = core[1] + (core[3] - core[1]) * (gy + 0.5) / 3
|
|
467
|
+
n += 1
|
|
468
|
+
if _in(bb, x, y) and _pip(poly, x, y):
|
|
469
|
+
n_in += 1
|
|
470
|
+
if n_in / n >= 0.12:
|
|
471
|
+
covered_by = p
|
|
472
|
+
if stroke and struck_by is None:
|
|
473
|
+
for pts, _closed in polys:
|
|
474
|
+
hit = None
|
|
475
|
+
for i in range(len(pts) - 1):
|
|
476
|
+
seg = _clip_seg(pts[i], pts[i + 1], core)
|
|
477
|
+
if seg is None:
|
|
478
|
+
continue
|
|
479
|
+
(ax, ay), (bx, by) = seg
|
|
480
|
+
for k in range(5):
|
|
481
|
+
u = (k + 0.5) / 5
|
|
482
|
+
x, y = ax + (bx - ax) * u, ay + (by - ay) * u
|
|
483
|
+
if any(_in(h, x, y) for h in hs):
|
|
484
|
+
# v0.13.0: an edge drawn *through* someone else's label
|
|
485
|
+
# with a gap cut behind the text still reads as crossing
|
|
486
|
+
if gapped_by is None and _in(r.band, x, y) \
|
|
487
|
+
and hasattr(p.owner, "src") and p.owner is not owner \
|
|
488
|
+
and not _related(owner, p.owner) \
|
|
489
|
+
and getattr(owner, "label_owner", None) is not p.owner \
|
|
490
|
+
and not _edge_inside(p.owner, owner):
|
|
491
|
+
gapped_by = p
|
|
492
|
+
continue
|
|
493
|
+
if hidden(x, y, idx):
|
|
494
|
+
continue
|
|
495
|
+
if _in(r.band, x, y):
|
|
496
|
+
hit = "struck"
|
|
497
|
+
break
|
|
498
|
+
hit = hit or "grazed"
|
|
499
|
+
if hit == "struck":
|
|
500
|
+
break
|
|
501
|
+
if hit == "struck":
|
|
502
|
+
struck_by = p
|
|
503
|
+
break
|
|
504
|
+
if hit == "grazed" and grazed_by is None:
|
|
505
|
+
grazed_by = p
|
|
506
|
+
if covered_by is not None:
|
|
507
|
+
res["covered"].append((r, covered_by, core))
|
|
508
|
+
elif struck_by is not None:
|
|
509
|
+
res["struck"].append((r, struck_by, core))
|
|
510
|
+
elif grazed_by is not None:
|
|
511
|
+
res["grazed"].append((r, grazed_by, core))
|
|
512
|
+
if gapped_by is not None and covered_by is None and struck_by is None:
|
|
513
|
+
res["gapped"].append((r, gapped_by, core))
|
|
514
|
+
# text vs text
|
|
515
|
+
runs_sorted = sorted(runs, key=lambda r: r.core[0])
|
|
516
|
+
for i, a in enumerate(runs_sorted):
|
|
517
|
+
if ignored(a.p.chain, "CF-TXT-010"):
|
|
518
|
+
continue
|
|
519
|
+
for b in runs_sorted[i + 1:]:
|
|
520
|
+
if b.core[0] > a.core[2]:
|
|
521
|
+
break
|
|
522
|
+
if a.p.text_id == b.p.text_id or ignored(b.p.chain, "CF-TXT-010"):
|
|
523
|
+
continue
|
|
524
|
+
ov = _inter(a.core, b.core)
|
|
525
|
+
if ov > 0.15 * min(_area(a.core), _area(b.core)):
|
|
526
|
+
res["text"].append((a, b, (max(a.core[0], b.core[0]), max(a.core[1], b.core[1]),
|
|
527
|
+
min(a.core[2], b.core[2]), min(a.core[3], b.core[3]))))
|
|
528
|
+
return res
|