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/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