"""IDEF0 diagrams from a simple text DSL (FIPS 183 / IEEE 1320.1). Fence language: ```idef0 (alias ```uml-idef0). Rendered locally with Pillow — no PlantUML / Kroki. """ from __future__ import annotations import logging import math import os import re import xml.sax.saxutils from dataclasses import dataclass, field from pathlib import Path from PIL import Image, ImageDraw, ImageFont _log = logging.getLogger(__name__) CACHE_KEY_PREFIX = "idef0-v6\n" # --- model ----------------------------------------------------------------- Side = str # "I" | "C" | "O" | "M" | "CALL" _SIDES = frozenset({"I", "C", "O", "M", "CALL"}) _SIDE_FROM_TOKEN = { "i": "I", "in": "I", "input": "I", "c": "C", "ctrl": "C", "control": "C", "^": "C", "o": "O", "out": "O", "output": "O", "m": "M", "mech": "M", "mechanism": "M", "v": "M", "call": "CALL", } _STATUS_CANON = { "working": "WORKING", "draft": "DRAFT", "recommended": "RECOMMENDED", "publication": "PUBLICATION", "рабочий": "WORKING", "черновик": "DRAFT", "рекомендован": "RECOMMENDED", "рекомендовано": "RECOMMENDED", "публикация": "PUBLICATION", } _STATUSES = ("WORKING", "DRAFT", "RECOMMENDED", "PUBLICATION") # Longer keys first. _META_ALIASES: list[tuple[str, str]] = [ ("used at", "used_at"), ("used_at", "used_at"), ("used-at", "used_at"), ("reader date", "reader_date"), ("reader_date", "reader_date"), ("reader-date", "reader_date"), ("author", "author"), ("project", "project"), ("date", "date"), ("revision", "rev"), ("rev", "rev"), ("status", "status"), ("context", "context"), ("node", "node"), ("title", "title"), ("number", "number"), ("notes", "notes"), ("reader", "reader"), ("purpose", "purpose"), ("viewpoint", "viewpoint"), ("page", "page"), ("form", "form"), ("автор", "author"), ("проект", "project"), ("дата", "date"), ("узел", "node"), ("название", "title"), ("номер", "number"), ("статус", "status"), ("контекст", "context"), ("цель", "purpose"), ("точка зрения", "viewpoint"), ] _BOX_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") _A_NODE_RE = re.compile(r"^A-?\d+[A-Za-z0-9]*$", re.I) _BOX_DEF_BRACKET = re.compile( r"^\[([A-Za-z][A-Za-z0-9_-]*)\]\s*(.*)$", ) _BOX_DEF_KW = re.compile( r"^box\s+([A-Za-z][A-Za-z0-9_-]*)\s*(.*)$", re.I, ) _NOT_BOX_REST = re.compile( r"^(?:in|input|out|output|ctrl|control|mech|mechanism|call|[ICOM])\b|^<-|^->|^\^|^v\b", re.I, ) _ICOM_TAIL = re.compile(r"\[([ICOM]\d+)\]\s*$", re.I) _KW_ARROW = re.compile( r"^(\(?)\s*(?:([A-Za-z][A-Za-z0-9_-]*)\s+)?" r"(\(?)(in|input|out|output|ctrl|control|mech|mechanism|call|[ICOM])\b(\)?)" r"\s*(.*)$", re.I, ) class Idef0ParseError(ValueError): """Invalid IDEF0 DSL.""" @dataclass class Idef0Box: id: str name: str number: str = "" # lower-right corner; default = id @dataclass class Idef0End: """One end of an arrow: a box port, or the diagram boundary.""" box: str | None side: Side tunnel: bool = False @dataclass class Idef0Arrow: sources: list[Idef0End] targets: list[Idef0End] label: str = "" icom: str = "" # e.g. I1, C2 — drawn near the unconnected end @dataclass class Idef0Diagram: title: str = "" node: str = "" number: str = "1" author: str = "" project: str = "" date: str = "" rev: str = "" status: str = "" context: str = "" used_at: str = "" reader: str = "" reader_date: str = "" notes: str = "" purpose: str = "" viewpoint: str = "" page: str = "" form: str = "kit" # kit | plain boxes: list[Idef0Box] = field(default_factory=list) arrows: list[Idef0Arrow] = field(default_factory=list) def box_ids(self) -> dict[str, Idef0Box]: return {b.id: b for b in self.boxes} # --- parse ----------------------------------------------------------------- def _strip_comment(line: str) -> str: if line.lstrip().startswith("#"): return "" return line def _unquote(text: str) -> str: text = text.strip() if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": return text[1:-1].strip() return text def _match_meta(line: str) -> tuple[str, str] | None: raw = line.strip() low = raw.lower() for prefix, key in _META_ALIASES: if not low.startswith(prefix): continue rest = raw[len(prefix) :] if rest == "": return key, "" if rest[0] in ":\t ": return key, _unquote(rest.lstrip(":\t ")) return None def _looks_like_box_id(name: str, known: set[str]) -> bool: if name in known: return True if _A_NODE_RE.match(name): return True if re.match(r"^[1-6]$", name): return True return False def _parse_side_token(token: str | None, default: Side) -> Side: if not token: return default key = token.strip().lower().lstrip(".") if key in _SIDE_FROM_TOKEN: return _SIDE_FROM_TOKEN[key] if key.upper() in _SIDES: return key.upper() # type: ignore[return-value] return default def _parse_end_token(token: str, default_side: Side, known: set[str]) -> Idef0End | None: text = token.strip() if not text: return None tunnel = False if text.startswith("(") and text.endswith(")") and len(text) > 2: tunnel = True text = text[1:-1].strip() elif text.startswith("("): tunnel = True text = text[1:].strip() elif text.endswith(")"): tunnel = True text = text[:-1].strip() m = re.match( r"^([A-Za-z][A-Za-z0-9_-]*)(?:\.([A-Za-z]+))?$", text, ) if not m or not _looks_like_box_id(m.group(1), known): return None return Idef0End(box=m.group(1), side=_parse_side_token(m.group(2), default_side), tunnel=tunnel) def _consume_ends( text: str, default_side: Side, known: set[str], ) -> tuple[list[Idef0End], str]: """Parse a comma-separated box-ref list from the start of text.""" rest = text.strip() ends: list[Idef0End] = [] while rest: m = re.match( r"^\s*(\(?[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z]+)?\)?)\s*(,\s*)?", rest, ) if not m: break end = _parse_end_token(m.group(1), default_side, known) if end is None: break ends.append(end) rest = rest[m.end() :] if not m.group(2): # no comma — only continue if next token is clearly another ref break return ends, rest.strip() def _split_label_icom(text: str) -> tuple[str, str]: text = text.strip() if text.startswith(":"): text = text[1:].strip() icom = "" m = _ICOM_TAIL.search(text) if m: icom = m.group(1).upper() text = text[: m.start()].strip() return _unquote(text), icom def _split_arrow_op(line: str) -> tuple[str, str, str] | None: for op in ("<-", "-->", "->"): i = line.find(op) if i >= 0: return line[:i], op, line[i + len(op) :] parts = line.split() for idx, tok in enumerate(parts): if tok in ("^", "v", "V"): left = " ".join(parts[:idx]) right = " ".join(parts[idx + 1 :]) return left, tok, right return None def _ensure_box(diagram: Idef0Diagram, box_id: str, name: str = "") -> None: ids = {b.id for b in diagram.boxes} if box_id in ids: if name: for b in diagram.boxes: if b.id == box_id and not b.name: b.name = name return diagram.boxes.append(Idef0Box(id=box_id, name=name or box_id, number=box_id)) def _current_box_id(diagram: Idef0Diagram) -> str: if diagram.boxes: return diagram.boxes[-1].id return "A0" def _boundary_end(side: Side, tunnel: bool = False) -> Idef0End: return Idef0End(box=None, side=side, tunnel=tunnel) def _parse_arrow_line(line: str, diagram: Idef0Diagram, lineno: int) -> None: known = {b.id for b in diagram.boxes} raw = line.strip() tun_border = False tun_box = False work = raw if work.startswith("(") and not work.startswith("(A") and not re.match(r"^\([A-Za-z]", work): tun_border = True work = work[1:].lstrip() # keyword form: [box] in/out/ctrl/mech/call label km = _KW_ARROW.match(work) if km: tun_kw_b = bool(km.group(1) or km.group(3)) box_id = km.group(2) or _current_box_id(diagram) kw = km.group(4) tun_kw_box = bool(km.group(5)) rest = km.group(6) side = _parse_side_token(kw, "I") label, icom = _split_label_icom(rest) _ensure_box(diagram, box_id) box_end = Idef0End(box=box_id, side="M" if side == "CALL" else side, tunnel=tun_kw_box or tun_box) if side in ("I", "C", "M"): diagram.arrows.append( Idef0Arrow( sources=[_boundary_end(side, tun_kw_b or tun_border)], targets=[box_end], label=label, icom=icom, ) ) else: # O or CALL — leave the box out_side: Side = "CALL" if side == "CALL" else "O" box_end.side = out_side diagram.arrows.append( Idef0Arrow( sources=[box_end], targets=[_boundary_end(out_side, tun_kw_b or tun_border)], label=label, icom=icom, ) ) return split = _split_arrow_op(work) if not split: raise Idef0ParseError(f"IDEF0: не удалось разобрать строку {lineno}: {raw}") left, op, right = split left, right = left.strip(), right.strip() if left.endswith("("): tun_border = True left = left[:-1].strip() if right.startswith(")"): tun_box = True right = right[1:].strip() if op in ("<-",): side: Side = "I" elif op in ("^",): side = "C" elif op in ("v", "V"): side = "M" else: side = "O" if side == "O": srcs, left_rest = _consume_ends(left, "O", known) if left_rest: # leftover on the left is unexpected for output if not srcs: raise Idef0ParseError(f"IDEF0: не удалось разобрать строку {lineno}: {raw}") if not srcs: bid = _current_box_id(diagram) _ensure_box(diagram, bid) srcs = [Idef0End(box=bid, side="O", tunnel=tun_box)] else: for e in srcs: if e.side == "O" and "." not in (left or ""): e.side = "O" e.tunnel = e.tunnel or tun_box for e in srcs: if e.box: _ensure_box(diagram, e.box) tgts, tgt_rest = _consume_ends(right, "I", known) if tgts: label, icom = _split_label_icom(tgt_rest) for t in tgts: if t.box: _ensure_box(diagram, t.box) t.tunnel = t.tunnel or tun_border diagram.arrows.append(Idef0Arrow(sources=srcs, targets=tgts, label=label, icom=icom)) return label, icom = _split_label_icom(right) diagram.arrows.append( Idef0Arrow( sources=srcs, targets=[_boundary_end("O", tun_border)], label=label, icom=icom, ) ) return # Input / control / mechanism into a box (from boundary or from other boxes) srcs, src_rest = _consume_ends(left, "O", known) if srcs and not src_rest: # A1 -> already handled; here A1 ^ or "A2, A3 <-" wait, left of <- is the box pass if op == "<-": # `A1, A2 <- X` is unusual; `A1 <- label` or `A2 <- A1` (A1 output into A2) # If right starts with box refs: internal into this side box_left, left_rest = _consume_ends(left or "", "I", known) if not box_left: bid = _current_box_id(diagram) _ensure_box(diagram, bid) box_left = [Idef0End(box=bid, side="I", tunnel=tun_box)] else: for e in box_left: e.side = "I" e.tunnel = e.tunnel or tun_box if e.box: _ensure_box(diagram, e.box) from_boxes, from_rest = _consume_ends(right, "O", known) if from_boxes: label, icom = _split_label_icom(from_rest) for e in from_boxes: e.side = "O" if e.box: _ensure_box(diagram, e.box) diagram.arrows.append( Idef0Arrow(sources=from_boxes, targets=box_left, label=label, icom=icom) ) return label, icom = _split_label_icom(right) diagram.arrows.append( Idef0Arrow( sources=[_boundary_end("I", tun_border)], targets=box_left, label=label, icom=icom, ) ) return # ^ or v : `A1 ^ label` or `A1 ^ A2` (A2 as source? rare) — treat right as label # unless right is box refs (output of those boxes feeding this control/mech) box_left, _ = _consume_ends(left or "", side, known) if not box_left: bid = _current_box_id(diagram) _ensure_box(diagram, bid) box_left = [Idef0End(box=bid, side=side, tunnel=tun_box)] else: for e in box_left: e.side = side e.tunnel = e.tunnel or tun_box if e.box: _ensure_box(diagram, e.box) from_boxes, from_rest = _consume_ends(right, "O", known) if from_boxes: label, icom = _split_label_icom(from_rest) for e in from_boxes: e.side = "O" if e.box: _ensure_box(diagram, e.box) diagram.arrows.append( Idef0Arrow(sources=from_boxes, targets=box_left, label=label, icom=icom) ) return label, icom = _split_label_icom(right) diagram.arrows.append( Idef0Arrow( sources=[_boundary_end(side, tun_border)], targets=box_left, label=label, icom=icom, ) ) def _parse_box_line(line: str) -> Idef0Box | None: m = _BOX_DEF_BRACKET.match(line) if m: return Idef0Box(id=m.group(1), name=_unquote(m.group(2)) or m.group(1), number=m.group(1)) m = _BOX_DEF_KW.match(line) if m: return Idef0Box(id=m.group(1), name=_unquote(m.group(2)) or m.group(1), number=m.group(1)) # Shorthand: A0 Name (must look like an IDEF0 node id) m = re.match(r"^(A-?\d+[A-Za-z0-9]*)\s+(.+)$", line) if m and "->" not in line and "<-" not in line: rest = m.group(2).strip() if rest and rest[0] not in "^vV" and not _NOT_BOX_REST.match(rest) and not _KW_ARROW.match(line): if rest.startswith("(") or rest.startswith(")"): return None return Idef0Box(id=m.group(1), name=_unquote(rest), number=m.group(1)) return None def _canon_status(value: str) -> str: token = value.strip().split()[0] if value.strip() else "" return _STATUS_CANON.get(token.lower(), token.upper()) def parse_idef0(source: str) -> Idef0Diagram: """Parse an ```idef0 body into a diagram model.""" diagram = Idef0Diagram() pending: list[tuple[int, str]] = [] saw_content = False if not (source or "").strip(): raise Idef0ParseError("IDEF0: пустой блок") for lineno, raw in enumerate(source.splitlines(), 1): line = _strip_comment(raw).strip() if not line: continue saw_content = True meta = _match_meta(line) if meta: key, val = meta if key == "status": val = _canon_status(val) if hasattr(diagram, key): setattr(diagram, key, val) continue box = _parse_box_line(line) if box: _ensure_box(diagram, box.id, box.name) for b in diagram.boxes: if b.id == box.id: b.name = box.name or b.name b.number = box.number or b.number continue pending.append((lineno, line)) if not saw_content: raise Idef0ParseError("IDEF0: пустой блок") if not diagram.boxes: _ensure_box(diagram, "A0", diagram.title or "A0") for lineno, line in pending: _parse_arrow_line(line, diagram, lineno) for arrow in diagram.arrows: if not re.match(r"^O\d+$", arrow.icom or "", re.I): continue has_c = any(t.box and t.side == "C" for t in arrow.targets) has_bound = any(t.box is None for t in arrow.targets) if has_c and not has_bound: arrow.targets.append(_boundary_end("O")) if not diagram.node: if len(diagram.boxes) == 1 and diagram.boxes[0].id.upper() in {"A0", "A-0", "0"}: diagram.node = "A-0" else: diagram.node = "A0" if not diagram.title and diagram.boxes: diagram.title = diagram.boxes[0].name if not diagram.context and diagram.node.upper() in {"A-0", "A0"}: if diagram.node.upper() == "A-0": diagram.context = "TOP" if not diagram.page: diagram.page = diagram.number or "1" return diagram # --- fonts / geometry ------------------------------------------------------ def _font_candidates(bold: bool) -> list[Path]: names_win = ( ("arialbd.ttf", "segoeuib.ttf", "calibrib.ttf") if bold else ("arial.ttf", "segoeui.ttf", "calibri.ttf") ) names_unix = ( ("DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf", "FreeSansBold.ttf") if bold else ("DejaVuSans.ttf", "LiberationSans-Regular.ttf", "FreeSans.ttf") ) out: list[Path] = [] windir = os.environ.get("WINDIR") or r"C:\Windows" fonts_win = Path(windir) / "Fonts" for n in names_win: out.append(fonts_win / n) for root in ( Path("/usr/share/fonts/truetype/dejavu"), Path("/usr/share/fonts/truetype/liberation"), Path("/usr/share/fonts/truetype/freefont"), Path("/usr/share/fonts/TTF"), Path("/usr/share/fonts/truetype/msttcorefonts"), ): for n in names_unix: out.append(root / n) return out _FONT_CACHE: dict[tuple[int, bool], ImageFont.ImageFont] = {} def _load_font(size: int, bold: bool = False) -> ImageFont.ImageFont: size = max(8, int(size)) key = (size, bold) hit = _FONT_CACHE.get(key) if hit is not None: return hit for path in _font_candidates(bold): if path.is_file(): try: font = ImageFont.truetype(str(path), size=size) _FONT_CACHE[key] = font return font except OSError: continue font = ImageFont.load_default() _FONT_CACHE[key] = font return font def _text_wh(font: ImageFont.ImageFont, text: str) -> tuple[int, int]: if not text: return 0, 0 if hasattr(font, "getbbox"): l, t, r, b = font.getbbox(text) return max(0, r - l), max(0, b - t) if hasattr(font, "getsize"): w, h = font.getsize(text) # type: ignore[attr-defined] return int(w), int(h) return len(text) * 8, 12 def _wrap(text: str, font: ImageFont.ImageFont, max_width: int) -> list[str]: text = (text or "").strip() if not text: return [""] words = text.split() lines: list[str] = [] cur = "" for word in words: trial = word if not cur else f"{cur} {word}" if _text_wh(font, trial)[0] <= max_width or not cur: cur = trial else: lines.append(cur) cur = word if cur: lines.append(cur) return lines or [""] # --- drawing ops ----------------------------------------------------------- @dataclass class _Rect: x: float y: float w: float h: float @property def r(self) -> float: return self.x + self.w @property def b(self) -> float: return self.y + self.h @property def cx(self) -> float: return self.x + self.w / 2 @property def cy(self) -> float: return self.y + self.h / 2 def contains_point(self, px: float, py: float, pad: float = 0.0) -> bool: return self.x - pad <= px <= self.r + pad and self.y - pad <= py <= self.b + pad def inflate(self, p: float) -> "_Rect": return _Rect(self.x - p, self.y - p, self.w + 2 * p, self.h + 2 * p) def overlap_area(self, other: "_Rect") -> float: ix = max(0.0, min(self.r, other.r) - max(self.x, other.x)) iy = max(0.0, min(self.b, other.b) - max(self.y, other.y)) return ix * iy @dataclass class _BoxGeom: box: Idef0Box rect: _Rect slots: dict[str, list[tuple[int, Idef0Arrow, Idef0End]]] = field(default_factory=dict) class _Scene: def __init__(self, width: int, height: int): self.width = width self.height = height self.ops: list[tuple] = [] def line(self, x1, y1, x2, y2, width: float = 1.5) -> None: self.ops.append(("line", float(x1), float(y1), float(x2), float(y2), float(width))) def polyline(self, pts: list[tuple[float, float]], width: float = 1.5) -> None: if len(pts) < 2: return self.ops.append(("polyline", [(float(x), float(y)) for x, y in pts], float(width))) def rect( self, x, y, w, h, width: float = 1.5, fill: str | None = None, outline: str | None = "#000000", ) -> None: self.ops.append( ("rect", float(x), float(y), float(w), float(h), float(width), fill, outline) ) def polygon( self, pts: list[tuple[float, float]], fill: str = "#000000", outline: str = "#000000", width: float = 1, ) -> None: self.ops.append( ("polygon", [(float(x), float(y)) for x, y in pts], fill, outline, float(width)) ) def text( self, x, y, text: str, size: float, *, bold: bool = False, anchor: str = "lt", fill: str = "#000000", ) -> None: if not text: return self.ops.append(("text", float(x), float(y), text, float(size), bold, anchor, fill)) def arc(self, bbox: tuple[float, float, float, float], start: float, end: float, width: float) -> None: self.ops.append(("arc", tuple(float(v) for v in bbox), float(start), float(end), float(width))) def to_png(self) -> bytes: img = Image.new("RGB", (self.width, self.height), "#ffffff") draw = ImageDraw.Draw(img) for op in self.ops: kind = op[0] if kind == "line": _, x1, y1, x2, y2, w = op draw.line([(x1, y1), (x2, y2)], fill="#000000", width=max(1, int(round(w)))) elif kind == "polyline": _, pts, w = op draw.line(pts, fill="#000000", width=max(1, int(round(w)))) elif kind == "rect": _, x, y, w, h, lw, fill, outline = op xy = [x, y, x + w, y + h] stroke_w = max(1, int(round(lw))) if outline else 0 draw.rectangle(xy, fill=fill, outline=outline, width=stroke_w) elif kind == "polygon": _, pts, fill, outline, w = op draw.polygon(pts, fill=fill, outline=outline) elif kind == "text": _, x, y, text, size, bold, anchor, fill = op font = _load_font(int(round(size)), bold) draw.text((x, y), text, font=font, fill=fill, anchor=anchor) elif kind == "arc": _, bbox, start, end, w = op draw.arc(bbox, start, end, fill="#000000", width=max(1, int(round(w)))) from io import BytesIO buf = BytesIO() img.save(buf, format="PNG", optimize=True) return buf.getvalue() def to_svg(self) -> str: esc = xml.sax.saxutils.escape parts = [ f'', '', ] for op in self.ops: kind = op[0] if kind == "line": _, x1, y1, x2, y2, w = op parts.append( f'' ) elif kind == "polyline": _, pts, w = op d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) parts.append( f'' ) elif kind == "rect": _, x, y, w, h, lw, fill, outline = op fill_a = fill or "none" stroke = outline or "none" sw = 0 if outline is None else lw parts.append( f'' ) elif kind == "polygon": _, pts, fill, outline, w = op d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) parts.append( f'' ) elif kind == "text": _, x, y, text, size, bold, anchor, fill = op ta, dx, dy = _svg_anchor(anchor) weight = "700" if bold else "400" parts.append( f'' f"{esc(text)}" ) elif kind == "arc": _, bbox, start, end, w = op parts.append(_svg_arc(bbox, start, end, w)) parts.append("") return "\n".join(parts) + "\n" def _svg_anchor(anchor: str) -> tuple[str, str, str]: """Map PIL anchors to SVG text-anchor + baseline tweak.""" a = (anchor or "lt").lower() horiz = a[0] if a else "l" vert = a[1] if len(a) > 1 else "t" ta = {"l": "start", "m": "middle", "r": "end"}.get(horiz, "start") # PIL anchor is the point; SVG y is alphabetic baseline ≈ 0.8 em from top dy = {"t": "0.9em", "m": "0.35em", "b": "0"}.get(vert, "0.9em") return ta, "0", dy def _svg_arc(bbox, start: float, end: float, width: float) -> str: x0, y0, x1, y1 = bbox cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 rx, ry = abs(x1 - x0) / 2, abs(y1 - y0) / 2 import math def pt(deg: float) -> tuple[float, float]: rad = math.radians(deg) return cx + rx * math.cos(rad), cy + ry * math.sin(rad) sx, sy = pt(start) ex, ey = pt(end) large = 1 if abs(end - start) % 360 > 180 else 0 return ( f'' ) # --- layout + render ------------------------------------------------------- def _arrowhead(scene: _Scene, tip: tuple[float, float], direction: str, size: float, lw: float) -> None: """direction = where the arrow points: E W N S.""" x, y = tip s = size if direction == "E": pts = [(x, y), (x - s, y - s * 0.42), (x - s, y + s * 0.42)] elif direction == "W": pts = [(x, y), (x + s, y - s * 0.42), (x + s, y + s * 0.42)] elif direction == "S": pts = [(x, y), (x - s * 0.42, y - s), (x + s * 0.42, y - s)] else: pts = [(x, y), (x - s * 0.42, y + s), (x + s * 0.42, y + s)] scene.polygon(pts, fill="#000000", outline="#000000", width=lw) def _dir_for_into(side: Side) -> str: return {"I": "E", "C": "S", "O": "E", "M": "N", "CALL": "S"}[side] def _dir_for_out(side: Side) -> str: return {"I": "W", "C": "N", "O": "E", "M": "S", "CALL": "S"}[side] def _stub_dir_from_box(side: Side, leaving: bool = True) -> str: """Outward from a box face (first/last elbow of the route).""" return {"I": "W", "C": "N", "O": "E", "M": "S", "CALL": "S"}[side] def _end_stub_dir(end: Idef0End) -> str: """Direction from the endpoint toward the interior of the route.""" if end.box: return _stub_dir_from_box(end.side) # Boundary: step into the diagram, not into the title block. return {"I": "E", "C": "S", "O": "W", "M": "N", "CALL": "N"}[end.side] def _offset(pt: tuple[float, float], direction: str, dist: float) -> tuple[float, float]: x, y = pt if direction == "E": return x + dist, y if direction == "W": return x - dist, y if direction == "N": return x, y - dist return x, y + dist def _port_point(geom: _BoxGeom, side: Side, index: int, count: int) -> tuple[float, float]: r = geom.rect n = max(count, 1) if n == 1: t = 0.5 else: pad = min(0.22, 0.48 / n) t = pad + (1.0 - 2.0 * pad) * (index / (n - 1)) if side in ("I",): return r.x, r.y + r.h * t if side in ("O",): return r.r, r.y + r.h * t if side in ("C",): return r.x + r.w * t, r.y return r.x + r.w * t, r.b def _boundary_point(work: _Rect, side: Side, along: float) -> tuple[float, float]: if side == "I": return work.x, along if side == "O": return work.r, along if side == "C": return along, work.y if side == "CALL": return along, work.b return along, work.b def _dedupe_pts(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: out: list[tuple[float, float]] = [] for p in pts: if not out or abs(out[-1][0] - p[0]) > 0.8 or abs(out[-1][1] - p[1]) > 0.8: out.append(p) return out def _simplify_ortho(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: """Dedupe, merge colinear runs, drop overshoot/backtracking on the same axis.""" pts = _dedupe_pts(pts) if len(pts) < 3: return pts merged: list[tuple[float, float]] = [pts[0]] for i in range(1, len(pts) - 1): a = merged[-1] b = pts[i] c = pts[i + 1] colinear = (abs(a[0] - b[0]) < 1.2 and abs(b[0] - c[0]) < 1.2) or ( abs(a[1] - b[1]) < 1.2 and abs(b[1] - c[1]) < 1.2 ) if colinear: continue merged.append(b) merged.append(pts[-1]) return _dedupe_pts(merged) def _polyline_backtracks(pts: list[tuple[float, float]]) -> bool: pts = _simplify_ortho(pts) for a, b, c in zip(pts, pts[1:], pts[2:]): if abs(a[0] - b[0]) < 1.2 and abs(b[0] - c[0]) < 1.2: if (b[1] - a[1]) * (c[1] - b[1]) < -1.0: return True if abs(a[1] - b[1]) < 1.2 and abs(b[1] - c[1]) < 1.2: if (b[0] - a[0]) * (c[0] - b[0]) < -1.0: return True return False def _seg_aabb(a: tuple[float, float], b: tuple[float, float], r: _Rect) -> bool: dx, dy = b[0] - a[0], b[1] - a[1] t0, t1 = 0.0, 1.0 for p, q in ( (-dx, a[0] - r.x), (dx, r.r - a[0]), (-dy, a[1] - r.y), (dy, r.b - a[1]), ): if abs(p) < 1e-9: if q < 0: return False continue t = q / p if p < 0: if t > t1: return False if t > t0: t0 = t else: if t < t0: return False if t < t1: t1 = t return t1 >= t0 def _seg_hits_open_box(a: tuple[float, float], b: tuple[float, float], r: _Rect, inset: float = 4.0) -> bool: inner = r.inflate(-inset) if inner.w <= 2 or inner.h <= 2: return False return _seg_aabb(a, b, inner) def _seg_obstacle(a: tuple[float, float], b: tuple[float, float], half: float) -> _Rect: x0, x1 = min(a[0], b[0]) - half, max(a[0], b[0]) + half y0, y1 = min(a[1], b[1]) - half, max(a[1], b[1]) + half return _Rect(x0, y0, x1 - x0, y1 - y0) def _take_free(used: list[float], hint: float, lo: float, hi: float, gap: float) -> float: if hi < lo: lo, hi = hi, lo if hi - lo < gap * 0.4: used.append((lo + hi) / 2) return used[-1] hint = min(max(hint, lo), hi) for step in range(0, 56): delta = (step + 1) // 2 * gap * 0.5 x = hint + (delta if step % 2 == 0 else -delta) x = min(max(x, lo), hi) if all(abs(x - u) >= gap for u in used): used.append(x) return x used.append(hint) return hint def _route(p1: tuple[float, float], d1: str, p2: tuple[float, float], d2: str, stub: float) -> list[tuple[float, float]]: """Elbow helper; ICOM routing uses _route_icom instead.""" a = _offset(p1, d1, stub) b = _offset(p2, d2, stub) pts = [p1, a] if abs(a[0] - b[0]) < 1.5 or abs(a[1] - b[1]) < 1.5: pts.append(b) elif d1 in ("E", "W"): pts.extend([(b[0], a[1]), b]) else: pts.extend([(a[0], b[1]), b]) pts.append(p2) return _simplify_ortho(pts) def _draw_tunnel(scene: _Scene, pt: tuple[float, float], horizontal: bool, s: float, lw: float) -> None: x, y = pt r = 7 * s if horizontal: scene.arc((x - r, y - r, x + r * 0.15, y + r), 70, 290, lw) scene.arc((x - r * 0.15, y - r, x + r, y + r), 250, 110, lw) else: scene.arc((x - r, y - r, x + r, y + r * 0.15), 160, 20, lw) scene.arc((x - r, y - r * 0.15, x + r, y + r), 340, 200, lw) def _draw_context_cell( scene: _Scene, d: Idef0Diagram, cell: _Rect, s: float, fonts: dict, lw: float, ) -> None: """CONTEXT thumbnail: small TOP, or a mini parent box / staircase.""" scene.text(cell.x + 6 * s, cell.y + 4 * s, "CONTEXT:", fonts["lab"], anchor="lt") ctx = d.context or ("TOP" if (d.node or "").upper() == "A-0" else d.node) if (ctx or "").upper() == "TOP": scene.text(cell.cx, cell.cy + 8 * s, "TOP", 12 * s, bold=True, anchor="mm") return child: int | None = None m = re.match(r"^A(\d+)$", (d.node or "").strip(), re.I) if m and int(m.group(1)) >= 1: child = int(m.group(1)) # A-0 parent is a single box; A0+ child diagrams get a mini staircase. if child is None or (ctx or "").upper() in {"A-0"}: bw, bh = min(58 * s, cell.w * 0.46), min(28 * s, cell.h * 0.32) bx, by = cell.cx - bw / 2, cell.y + 32 * s sh = 2.0 * s scene.rect(bx + sh, by + sh, bw, bh, width=0, fill="#c4c4c4", outline=None) scene.rect(bx, by, bw, bh, width=lw, fill="#ffffff") scene.text(bx + bw - 4 * s, by + bh - 3 * s, ctx, fonts["small"], bold=True, anchor="rb") return n = 4 bw, bh = 15 * s, 9 * s step_x, step_y = 8 * s, 7 * s total_w = bw + step_x * (n - 1) total_h = bh + step_y * (n - 1) x0 = cell.cx - total_w / 2 y0 = cell.y + 24 * s fill_i = min(max(child, 1), n) - 1 for i in range(n): x, y = x0 + i * step_x, y0 + i * step_y scene.rect(x, y, bw, bh, width=max(0.8, lw * 0.85), fill="#000000" if i == fill_i else "#ffffff") scene.text(cell.x + 6 * s, min(cell.b - 5 * s, y0 + total_h + 4 * s), ctx, fonts["small"], bold=True, anchor="lt") def _draw_kit_form(scene: _Scene, d: Idef0Diagram, outer: _Rect, s: float, fonts: dict) -> _Rect: """NIST / KBSI IDEF0 sheet. Returns the inner working rectangle.""" lw = max(1.0, 1.0 * s) # double outer frame scene.rect(outer.x, outer.y, outer.w, outer.h, width=lw) inset = 3.2 * s scene.rect(outer.x + inset, outer.y + inset, outer.w - 2 * inset, outer.h - 2 * inset, width=lw) inner = _Rect(outer.x + inset, outer.y + inset, outer.w - 2 * inset, outer.h - 2 * inset) header_h = 96 * s footer_h = 64 * s hy = inner.y fy = inner.b - footer_h # header / footer separators scene.line(inner.x, hy + header_h, inner.r, hy + header_h, lw) scene.line(inner.x, fy, inner.r, fy, lw) # column x positions inside inner x0 = inner.x w = inner.w x_used = x0 w_used = 0.145 * w x_auth = x_used + w_used w_auth = 0.355 * w x_stat = x_auth + w_auth w_stat = 0.175 * w x_read = x_stat + w_stat w_read = 0.145 * w x_ctx = x_read + w_read w_ctx = inner.r - x_ctx for x in (x_auth, x_stat, x_read, x_ctx): scene.line(x, hy, x, hy + header_h, lw) # DATE/REV row inside status column date_row = 22 * s scene.line(x_stat, hy + date_row, x_read, hy + date_row, lw) scene.line(x_stat + w_stat * 0.55, hy, x_stat + w_stat * 0.55, hy + date_row, lw) lab = fonts["lab"] val = fonts["val"] small = fonts["small"] scene.text(x_used + 6 * s, hy + 5 * s, "USED AT:", lab, anchor="lt") if d.used_at: for i, ln in enumerate(_wrap(d.used_at, _load_font(int(val), False), int(w_used - 12 * s))[:4]): scene.text(x_used + 6 * s, hy + 22 * s + i * 14 * s, ln, val, anchor="lt") scene.text(x_auth + 6 * s, hy + 4 * s, "AUTHOR:", lab, anchor="lt") scene.text(x_auth + 62 * s, hy + 4 * s, d.author, val, bold=False, anchor="lt") scene.text(x_auth + 6 * s, hy + 22 * s, "PROJECT:", lab, anchor="lt") proj_lines = _wrap(d.project, _load_font(int(small), False), int(w_auth - 78 * s)) if proj_lines: scene.text(x_auth + 70 * s, hy + 22 * s, proj_lines[0], small, anchor="lt") for i, ln in enumerate(proj_lines[1:3], 1): scene.text(x_auth + 70 * s, hy + 22 * s + i * 13 * s, ln, small, anchor="lt") notes_y = hy + 54 * s scene.text(x_auth + 6 * s, notes_y, "NOTES:", lab, anchor="lt") marked = set() for tok in re.findall(r"\d+", d.notes or ""): marked.add(int(tok)) nx = x_auth + 58 * s for n in range(1, 11): scene.text(nx, notes_y, str(n), small, bold=n in marked, anchor="lt") nx += 14 * s scene.text(x_stat + 5 * s, hy + 4 * s, "DATE:", lab, anchor="lt") scene.text(x_stat + 42 * s, hy + 4 * s, d.date, val, anchor="lt") scene.text(x_stat + w_stat * 0.55 + 4 * s, hy + 4 * s, "REV:", lab, anchor="lt") scene.text(x_stat + w_stat * 0.55 + 36 * s, hy + 4 * s, d.rev, val, anchor="lt") st_y = hy + date_row + 6 * s box_s = 8 * s for i, name in enumerate(_STATUSES): yy = st_y + i * 16 * s filled = name == d.status scene.rect( x_stat + 6 * s, yy + 1 * s, box_s, box_s, width=lw, fill="#000000" if filled else None, ) scene.text(x_stat + 18 * s, yy, name, small, bold=filled, anchor="lt") scene.text(x_read + 6 * s, hy + 4 * s, "READER", lab, anchor="lt") scene.text(x_read + 6 * s, hy + 22 * s, d.reader, val, anchor="lt") scene.line(x_read, hy + date_row, x_ctx, hy + date_row, lw) scene.text(x_read + 6 * s, hy + date_row + 4 * s, "DATE", lab, anchor="lt") scene.text(x_read + 6 * s, hy + date_row + 20 * s, d.reader_date, val, anchor="lt") _draw_context_cell(scene, d, _Rect(x_ctx, hy, w_ctx, header_h), s, fonts, lw) # footer fn_w = 0.18 * inner.w ft_x = inner.x + fn_w ft_w = 0.62 * inner.w fnum_x = ft_x + ft_w scene.line(ft_x, fy, ft_x, inner.b, lw) scene.line(fnum_x, fy, fnum_x, inner.b, lw) scene.text(inner.x + 6 * s, fy + 4 * s, "NODE:", lab, anchor="lt") scene.text(inner.x + fn_w / 2, fy + 38 * s, d.node, fonts["node"], bold=True, anchor="mm") scene.text(ft_x + 6 * s, fy + 4 * s, "TITLE:", lab, anchor="lt") title_font = _load_font(int(fonts["title"]), True) tlines = _wrap(d.title, title_font, int(ft_w - 16 * s)) if len(tlines) == 1: scene.text(ft_x + ft_w / 2, fy + 38 * s, tlines[0], fonts["title"], bold=True, anchor="mm") else: for i, ln in enumerate(tlines[:2]): scene.text(ft_x + ft_w / 2, fy + 26 * s + i * 18 * s, ln, fonts["title"] * 0.85, bold=True, anchor="mm") scene.text(fnum_x + 6 * s, fy + 4 * s, "NUMBER:", lab, anchor="lt") scene.text(fnum_x + (inner.r - fnum_x) / 2, fy + 36 * s, d.number, fonts["node"], bold=True, anchor="mm") # tiny page box, bottom-right of the form pb = 18 * s scene.rect(inner.r - pb, inner.b - pb, pb, pb, width=lw) scene.text(inner.r - pb / 2, inner.b - pb / 2, d.page, small, bold=True, anchor="mm") work = _Rect(inner.x + 8 * s, hy + header_h + 8 * s, inner.w - 16 * s, fy - (hy + header_h) - 16 * s) return work def _assign_slots(diagram: Idef0Diagram, geoms: dict[str, _BoxGeom]) -> None: for g in geoms.values(): g.slots = {"I": [], "C": [], "O": [], "M": [], "CALL": []} for ai, arrow in enumerate(diagram.arrows): for end in arrow.sources + arrow.targets: if end.box and end.box in geoms: side = end.side if end.side in geoms[end.box].slots else "M" geoms[end.box].slots[side].append((ai, arrow, end)) def _slot_index(geom: _BoxGeom, arrow: Idef0Arrow, end: Idef0End) -> tuple[int, int]: side = end.side if end.side in geom.slots else "M" items = geom.slots[side] for i, (_ai, ar, e) in enumerate(items): if ar is arrow and e is end: return i, len(items) return 0, max(len(items), 1) def _layout_boxes(diagram: Idef0Diagram, work: _Rect, s: float) -> dict[str, _BoxGeom]: """BPwin-style cluster: compact staircase, large ICOM margins, do not stretch to fill.""" n = len(diagram.boxes) geoms: dict[str, _BoxGeom] = {} c_h, m_h = 84 * s, 96 * s i_w, o_w = 108 * s, 100 * s inner = _Rect( work.x + i_w, work.y + c_h, max(180 * s, work.w - i_w - o_w), max(140 * s, work.h - c_h - m_h), ) if n == 1: bw, bh = min(320 * s, inner.w * 0.42), min(138 * s, inner.h * 0.36) rect = _Rect(inner.cx - bw / 2, inner.cy - bh / 2, bw, bh) geoms[diagram.boxes[0].id] = _BoxGeom(diagram.boxes[0], rect) return geoms bw = min(200 * s, inner.w * 0.34) bh = min(86 * s, inner.h * 0.24) # Compact staircase, but keep a corridor so O→I can drop between boxes # (0.85*bw alone overlaps; Kinzyabulatov A1 has a visible gap). step_x = bw + 56 * s step_y = bh + 58 * s total_w = bw + step_x * (n - 1) total_h = bh + step_y * (n - 1) if n > 1 and total_w > inner.w: step_x = max(bw * 0.55, (inner.w - bw) / (n - 1)) total_w = bw + step_x * (n - 1) if n > 1 and total_h > inner.h: step_y = max(bh * 0.7, (inner.h - bh) / (n - 1)) total_h = bh + step_y * (n - 1) x0 = inner.x + max(0.0, (inner.w - total_w) / 2) y0 = inner.y + max(0.0, (inner.h - total_h) / 2) for i, box in enumerate(diagram.boxes): geoms[box.id] = _BoxGeom(box, _Rect(x0 + i * step_x, y0 + i * step_y, bw, bh)) return geoms def _draw_box(scene: _Scene, geom: _BoxGeom, s: float, lw: float, fonts: dict) -> None: r = geom.rect sh = 4.0 * s scene.rect(r.x + sh, r.y + sh, r.w, r.h, width=0, fill="#c0c0c0", outline=None) scene.rect(r.x, r.y, r.w, r.h, width=max(1.0, lw), fill="#ffffff") # BPwin "has child" tick in the top-left corner. ts = 8 * s scene.polygon( [(r.x, r.y), (r.x + ts, r.y), (r.x, r.y + ts)], fill="#000000", outline="#000000", width=1, ) name_font = _load_font(int(fonts["box"]), False) lines = _wrap(geom.box.name, name_font, int(r.w - 18 * s)) total_h = len(lines) * fonts["box"] * 1.15 y0 = r.cy - total_h / 2 + fonts["box"] * 0.35 for i, ln in enumerate(lines[:4]): scene.text(r.cx, y0 + i * fonts["box"] * 1.15, ln, fonts["box"], anchor="mm") scene.text(r.r - 6 * s, r.b - 5 * s, geom.box.number or geom.box.id, fonts["id"], anchor="rb") def _end_point( end: Idef0End, arrow: Idef0Arrow, geoms: dict[str, _BoxGeom], work: _Rect, mate: Idef0End | None = None, ) -> tuple[float, float]: if end.box and end.box in geoms: g = geoms[end.box] idx, cnt = _slot_index(g, arrow, end) return _port_point(g, end.side, idx, cnt) other_y = work.cy other_x = work.cx mates = [mate] if mate is not None else ( arrow.targets if end in arrow.sources else arrow.sources ) xs, ys = [], [] for m in mates: if m is None or not m.box or m.box not in geoms: continue g = geoms[m.box] idx, cnt = _slot_index(g, arrow, m) px, py = _port_point(g, m.side, idx, cnt) xs.append(px) ys.append(py) if xs: other_x = sum(xs) / len(xs) other_y = sum(ys) / len(ys) return _boundary_point(work, end.side, other_y if end.side in ("I", "O") else other_x) def _box_order(diagram: Idef0Diagram) -> dict[str, int]: return {b.id: i for i, b in enumerate(diagram.boxes)} def _is_feedback( src: Idef0End, tgt: Idef0End, order: dict[str, int], ) -> bool: if not src.box or not tgt.box: return False if src.box not in order or tgt.box not in order: return False return order[src.box] > order[tgt.box] and src.side == "O" and tgt.side == "I" @dataclass class _Channels: work: _Rect stub: float min_gap: float c_lanes: dict[int, float] m_lanes: dict[int, float] i_x: float o_x: float m_y: float box_top: float box_bot: float used_v: list[float] = field(default_factory=list) used_h: list[float] = field(default_factory=list) def _make_channels( diagram: Idef0Diagram, geoms: dict[str, _BoxGeom], work: _Rect, s: float, ) -> _Channels: box_top = min((g.rect.y for g in geoms.values()), default=work.y + 70 * s) box_bot = max((g.rect.b for g in geoms.values()), default=work.b - 70 * s) c_arrows = [ a for a in diagram.arrows if any(e.box is None and e.side == "C" for e in a.sources) ] m_arrows = [ a for a in diagram.arrows if any(e.box is None and e.side == "M" for e in a.sources) ] c_arrows.sort(key=lambda a: (-len(a.targets), a.icom or a.label)) m_arrows.sort(key=lambda a: (-len(a.targets), a.icom or a.label)) c_lanes: dict[int, float] = {} for k, a in enumerate(c_arrows): y = work.y + 24 * s + k * 32 * s c_lanes[id(a)] = min(y, box_top - 22 * s) m_lanes: dict[int, float] = {} for k, a in enumerate(m_arrows): y = box_bot + 24 * s + k * 32 * s m_lanes[id(a)] = min(y, work.b - 18 * s) used_h = list(c_lanes.values()) + list(m_lanes.values()) return _Channels( work=work, stub=28 * s, min_gap=32 * s, c_lanes=c_lanes, m_lanes=m_lanes, i_x=work.x + 18 * s, o_x=work.r - 18 * s, m_y=min(work.b - 16 * s, max(box_bot + 56 * s, work.b - 28 * s)), box_top=box_top, box_bot=box_bot, used_v=[], used_h=used_h, ) def _gutter_x( p1: tuple[float, float], p2: tuple[float, float], geoms: dict[str, _BoxGeom], skip: set[str], stub: float, ) -> float: lo, hi = (p1[0], p2[0]) if p1[0] <= p2[0] else (p2[0], p1[0]) candidates = [ (p1[0] + p2[0]) / 2, p1[0] + stub, p2[0] - stub, lo + stub, hi - stub, ] y1, y2 = p1[1], p2[1] for gx in candidates: if gx < lo - 1 or gx > hi + 1: continue ok = True for gid, g in geoms.items(): if gid in skip: continue if _seg_hits_open_box((gx, y1), (gx, y2), g.rect) or _seg_hits_open_box( (p1[0], y1), (gx, y1), g.rect ): ok = False break if ok: return gx return max(lo + stub, min(hi - stub, (p1[0] + p2[0]) / 2)) def _route_icom( src: Idef0End, tgt: Idef0End, p1: tuple[float, float], p2: tuple[float, float], ch: _Channels, geoms: dict[str, _BoxGeom], order: dict[str, int], arrow: Idef0Arrow, lane_y: float | None = None, spine_x: float | None = None, ) -> list[tuple[float, float]]: stub = ch.stub skip = {b for b in (src.box, tgt.box) if b} if src.box is None and tgt.box and src.side == "C": if spine_x is not None and lane_y is not None: y = lane_y x_drop = p2[0] x_in = spine_x return _simplify_ortho([ (x_in, ch.work.y), (x_in, y), (x_drop, y), (x_drop, p2[1]), ]) return _simplify_ortho([(p2[0], ch.work.y), p2]) if src.box is None and tgt.box and src.side == "I": return _simplify_ortho([(ch.work.x, p2[1]), p2]) if src.box is None and tgt.box and src.side == "M": if spine_x is not None and lane_y is not None: y = lane_y x_up = p2[0] x_in = spine_x return _simplify_ortho([ (x_in, ch.work.b), (x_in, y), (x_up, y), (x_up, p2[1]), ]) return _simplify_ortho([(p2[0], ch.work.b), p2]) if tgt.box is None and src.box and tgt.side == "O": return _simplify_ortho([p1, (ch.work.r, p1[1])]) if tgt.box is None and src.box and tgt.side == "CALL": return _simplify_ortho([p1, (p1[0], ch.work.b)]) if src.box and tgt.box and src.side == "O" and tgt.side == "I": if _is_feedback(src, tgt, order): right = _take_free(ch.used_v, max(p1[0] + stub, ch.o_x), ch.o_x - 8, ch.work.r - 8, ch.min_gap) bottom = _take_free(ch.used_h, ch.m_y, ch.box_bot + 12, ch.work.b - 10, ch.min_gap) left = _take_free(ch.used_v, ch.i_x, ch.work.x + 8, ch.i_x + 40, ch.min_gap) return _simplify_ortho([ p1, (right, p1[1]), (right, bottom), (left, bottom), (left, p2[1]), p2, ]) src_r = geoms[src.box].rect tgt_r = geoms[tgt.box].rect if tgt_r.x >= src_r.r + 10: lo, hi = p1[0], p2[0] hint = _gutter_x(p1, p2, geoms, skip, stub) gx = _take_free(ch.used_v, hint, lo + stub * 0.45, hi - stub * 0.35, ch.min_gap * 0.6) return _simplify_ortho([p1, (gx, p1[1]), (gx, p2[1]), p2]) # Overlapping staircase: out, down the notch, in from the left of the target. gx = _take_free( ch.used_v, src_r.r + stub, src_r.r + stub * 0.55, src_r.r + stub * 2.8, ch.min_gap, ) left = _take_free( ch.used_v, tgt_r.x - stub, ch.work.x + 8, max(ch.work.x + 10, tgt_r.x - stub * 0.45), ch.min_gap, ) y_lo = min(src_r.b + 8, tgt_r.y - 8) y_hi = max(src_r.b + 8, tgt_r.y - 8) y_bar = _take_free(ch.used_h, (src_r.b + tgt_r.y) / 2, y_lo, y_hi, ch.min_gap) return _simplify_ortho([p1, (gx, p1[1]), (gx, y_bar), (left, y_bar), (left, p2[1]), p2]) if src.box and tgt.box and src.side == "O" and tgt.side == "C": clear_x = _take_free( ch.used_v, p1[0] + stub, p1[0] + stub * 0.7, p1[0] + stub * 3.2, ch.min_gap, ) y_bar = min(p2[1] - stub, geoms[tgt.box].rect.y - stub) y_bar = max(ch.work.y + 12, y_bar) y_bar = _take_free(ch.used_h, y_bar, ch.work.y + 10, geoms[tgt.box].rect.y - 12, ch.min_gap) return _simplify_ortho([ p1, (clear_x, p1[1]), (clear_x, y_bar), (p2[0], y_bar), p2, ]) if src.box and tgt.box and src.side == "O" and tgt.side == "M": clear_x = p1[0] + stub y_bar = max(p2[1] + stub, geoms[tgt.box].rect.b + stub) return _simplify_ortho([ p1, (clear_x, p1[1]), (clear_x, y_bar), (p2[0], y_bar), p2, ]) return _simplify_ortho([p1, p2]) def _poly_len(pts: list[tuple[float, float]]) -> float: tot = 0.0 for a, b in zip(pts, pts[1:]): tot += math.hypot(b[0] - a[0], b[1] - a[1]) return tot def _shared_prefix(paths: list[list[tuple[float, float]]]) -> list[tuple[float, float]]: if not paths: return [] i = 0 while all(i < len(p) for p in paths): x0, y0 = paths[0][i] if any(abs(p[i][0] - x0) > 1.5 or abs(p[i][1] - y0) > 1.5 for p in paths): break i += 1 return paths[0][:i] def _shared_suffix(paths: list[list[tuple[float, float]]]) -> list[tuple[float, float]]: if not paths: return [] i = 0 while all(i < len(p) for p in paths): x0, y0 = paths[0][-1 - i] if any(abs(p[-1 - i][0] - x0) > 1.5 or abs(p[-1 - i][1] - y0) > 1.5 for p in paths): break i += 1 if i <= 0: return [] return paths[0][-i:] def _best_label_polyline(paths: list[list[tuple[float, float]]], min_len: float) -> list[tuple[float, float]]: """Prefer the fork/join spine; if it has a horizontal bar, label that.""" if not paths: return [] if len(paths) == 1: return paths[0] prefix = _shared_prefix(paths) if len(prefix) >= 2: y = prefix[-1][1] xs = [pt[0] for path in paths for pt in path if abs(pt[1] - y) < 2.5] if xs and max(xs) - min(xs) >= min_len: return _simplify_ortho(prefix + [(min(xs), y), (max(xs), y)]) suffix = _shared_suffix(paths) plen, slen = _poly_len(prefix), _poly_len(suffix) if plen >= min_len and plen >= slen: return prefix if slen >= min_len: return suffix return max(paths, key=_poly_len) def _segments( pts: list[tuple[float, float]], ) -> list[tuple[tuple[float, float], tuple[float, float], str, float]]: segs = [] for a, b in zip(pts, pts[1:]): dx, dy = b[0] - a[0], b[1] - a[1] length = math.hypot(dx, dy) orient = "H" if abs(dx) >= abs(dy) else "V" segs.append((a, b, orient, length)) return segs def _pick_label_segment( pts: list[tuple[float, float]], min_len: float, ) -> tuple[tuple[float, float], tuple[float, float], str] | None: segs = _segments(pts) if not segs: return None def score(sg: tuple) -> float: _a, _b, orient, length = sg sc = 40.0 if orient == "H" else 0.0 sc -= abs(length - 110.0) * 0.12 if length < min_len: sc -= 50 if length > 420: sc -= 90 return sc chosen = max(segs, key=score) return chosen[0], chosen[1], chosen[2] def _seg_attach( a: tuple[float, float], b: tuple[float, float], t: float = 0.48, ) -> tuple[float, float]: return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t def _closest_on_rect(r: _Rect, px: float, py: float) -> tuple[float, float]: if r.contains_point(px, py): dists = [ (px - r.x, (r.x, py)), (r.r - px, (r.r, py)), (py - r.y, (px, r.y)), (r.b - py, (px, r.b)), ] return min(dists, key=lambda t: t[0])[1] return min(max(px, r.x), r.r), min(max(py, r.y), r.b) def _clamp_rect(r: _Rect, work: _Rect) -> _Rect: w = min(r.w, work.w) h = min(r.h, work.h) x = min(max(r.x, work.x), work.r - w) y = min(max(r.y, work.y), work.b - h) return _Rect(x, y, w, h) def _label_block_size( lines: list[str], font: ImageFont.ImageFont, size: float, pad: float, ) -> tuple[float, float]: widths = [_text_wh(font, ln)[0] for ln in lines] or [0] line_h = size * 1.12 return max(widths) + 2 * pad, line_h * len(lines) + 2 * pad def _layout_arrow_label( pts: list[tuple[float, float]], label: str, font: ImageFont.ImageFont, size: float, s: float, work: _Rect, obstacles: list[_Rect], boxes: list[_Rect] | None = None, shafts: list[_Rect] | None = None, ) -> tuple[_Rect, tuple[float, float], tuple[float, float], list[str], str] | None: """Place a noun-phrase off the shaft (FIPS 183). Returns halo, attach, connect, lines, orient.""" label = (label or "").strip() if not label or len(pts) < 2: return None boxes = boxes or [] shafts = shafts or [] max_w = int(92 * s) lines = _wrap(label, font, max_w)[:3] pad = 2.8 * s tw, th = _label_block_size(lines, font, size, pad) picked = _pick_label_segment(pts, min_len=22 * s) if picked is None: return None a, b, orient = picked gap = 16 * s best = None best_score = -1e18 best_attach = _seg_attach(a, b) ts = (0.78, 0.68, 0.58, 0.48, 0.38, 0.28, 0.18, 0.88) for t in ts: attach = _seg_attach(a, b, t) ax, ay = attach if orient == "H": raw = [ ("above", ax - tw / 2, ay - gap - th), ("below", ax - tw / 2, ay + gap), ("aboveL", ax - tw, ay - gap - th), ("aboveR", ax, ay - gap - th), ("left", ax - gap - tw, ay - th / 2), ("right", ax + gap, ay - th / 2), ] else: prefer_left = ax > work.cx side_order = ( [("left", ax - gap - tw, ay - th / 2), ("right", ax + gap, ay - th / 2)] if prefer_left else [("right", ax + gap, ay - th / 2), ("left", ax - gap - tw, ay - th / 2)] ) raw = side_order + [ ("above", ax - tw / 2, ay - gap - th), ("below", ax - tw / 2, ay + gap), ] for i, (_side, x, y) in enumerate(raw): halo = _clamp_rect(_Rect(x, y, tw, th), work) score = 70.0 - i * 14 - abs(t - 0.5) * 12 if halo.contains_point(ax, ay, pad=3 * s): score -= 600 box_hit = sum(halo.overlap_area(box) for box in boxes) if box_hit > 1: score -= 1e5 + box_hit shaft_hit = sum(halo.overlap_area(sh) for sh in shafts) if shaft_hit > (6 * s) * (6 * s): score -= 2500 + shaft_hit / max(s * s, 1.0) * 4 for obs in obstacles: ov = halo.overlap_area(obs) if ov > 0: score -= ov / max(s * s, 1.0) * 10 if halo.x < work.x + 2 or halo.r > work.r - 2: score -= 25 if halo.y < work.y + 2 or halo.b > work.b - 2: score -= 25 if score > best_score: best_score = score best = halo best_attach = attach if best is None or best_score < -9e4: return None connect = _closest_on_rect(best, best_attach[0], best_attach[1]) return best, best_attach, connect, lines, orient def _draw_squiggle( scene: _Scene, p0: tuple[float, float], p1: tuple[float, float], s: float, lw: float, ) -> None: """FIPS 183 squiggle: a short tapered sine, not a lightning bolt.""" x0, y0 = p0 x1, y1 = p1 dx, dy = x1 - x0, y1 - y0 dist = math.hypot(dx, dy) if dist < 4 * s: return maxd = 15 * s if dist > maxd: ux, uy = dx / dist, dy / dist x1, y1 = x0 + ux * maxd, y0 + uy * maxd dx, dy, dist = x1 - x0, y1 - y0, maxd ux, uy = dx / dist, dy / dist nx, ny = -uy, ux amp = 2.3 * s n = 16 pts = [] for i in range(n + 1): t = i / n wave = math.sin(t * math.pi * 4.0) * math.sin(t * math.pi) pts.append((x0 + ux * dist * t + nx * amp * wave, y0 + uy * dist * t + ny * amp * wave)) scene.polyline(pts, width=max(lw * 0.8, 1.0)) def _draw_label_block(scene: _Scene, halo: _Rect, lines: list[str], size: float) -> None: scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) line_h = size * 1.12 total = line_h * len(lines) y0 = halo.cy - total / 2 + size * 0.36 for i, ln in enumerate(lines): scene.text(halo.cx, y0 + i * line_h, ln, size, anchor="mm") def _draw_arrow_label( scene: _Scene, pts: list[tuple[float, float]], label: str, s: float, lw: float, fonts: dict, work: _Rect, occupied: list[_Rect], boxes: list[_Rect], shafts: list[_Rect] | None = None, ) -> _Rect | None: font = _load_font(int(fonts["arrow"]), False) placed = _layout_arrow_label( pts, label, font, fonts["arrow"], s, work, occupied, boxes, shafts, ) if placed is None: return None halo, attach, connect, lines, _orient = placed dist = math.hypot(connect[0] - attach[0], connect[1] - attach[1]) if dist > 18 * s: _draw_squiggle(scene, connect, attach, s, lw) _draw_label_block(scene, halo, lines, fonts["arrow"]) occupied.append(halo.inflate(6 * s)) return halo def _icom_border_point( arrow: Idef0Arrow, geoms: dict[str, _BoxGeom], work: _Rect, ) -> tuple[Idef0End, tuple[float, float]] | None: """Where the ICOM arrow meets the inner work frame.""" bound = next((e for e in arrow.sources + arrow.targets if e.box is None), None) if bound is None: return None mates = arrow.targets if bound in arrow.sources else arrow.sources xs, ys = [], [] for m in mates: if not m.box or m.box not in geoms: continue g = geoms[m.box] idx, cnt = _slot_index(g, arrow, m) px, py = _port_point(g, m.side, idx, cnt) xs.append(px) ys.append(py) if bound.side == "C": x = (min(xs) + max(xs)) / 2 if xs else work.cx return bound, (x, work.y) if bound.side == "M": x = (min(xs) + max(xs)) / 2 if xs else work.cx return bound, (x, work.b) if bound.side == "I": y = (min(ys) + max(ys)) / 2 if ys else work.cy return bound, (work.x, y) if bound.side in ("O", "CALL"): y = (min(ys) + max(ys)) / 2 if ys else work.cy return bound, (work.r if bound.side == "O" else work.cx, work.b if bound.side == "CALL" else y) return bound, _end_point(bound, arrow, geoms, work) def _draw_icom_code( scene: _Scene, arrow: Idef0Arrow, geoms: dict[str, _BoxGeom], work: _Rect, s: float, fonts: dict, occupied: list[_Rect], ) -> None: if not arrow.icom: return hit = _icom_border_point(arrow, geoms, work) if hit is None: return bound, pt = hit font = _load_font(int(fonts["id"]), True) tw, th = _text_wh(font, arrow.icom) pad = 1.6 * s bx, by = pt if bound.side == "I": halo = _Rect(work.x + 2 * s, by - th - 5 * s, tw + 2 * pad, th + 2 * pad) elif bound.side == "O": halo = _Rect(work.r - tw - 2 * pad - 2 * s, by - th - 5 * s, tw + 2 * pad, th + 2 * pad) elif bound.side == "C": halo = _Rect(bx + 5 * s, work.y + 2 * s, tw + 2 * pad, th + 2 * pad) else: halo = _Rect(bx + 5 * s, work.b - th - 2 * pad - 2 * s, tw + 2 * pad, th + 2 * pad) halo = _clamp_rect(halo, work) scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) scene.text(halo.cx, halo.cy, arrow.icom, fonts["id"], bold=True, anchor="mm") occupied.append(halo.inflate(3 * s)) def _append_tunnel( tunnels: list[tuple[tuple[float, float], bool]], end: Idef0End, pt: tuple[float, float], s: float, ) -> None: if end.tunnel: tunnels.append((_offset(pt, _end_stub_dir(end), 10 * s), end.side in ("I", "O"))) def _seg_key(a: tuple[float, float], b: tuple[float, float]) -> tuple: pa = (round(a[0], 1), round(a[1], 1)) pb = (round(b[0], 1), round(b[1], 1)) return (pa, pb) if pa <= pb else (pb, pa) def _draw_arrow( scene: _Scene, arrow: Idef0Arrow, geoms: dict[str, _BoxGeom], work: _Rect, s: float, lw: float, order: dict[str, int], ch: _Channels, ) -> list[list[tuple[float, float]]]: srcs = arrow.sources tgts = arrow.targets paths: list[list[tuple[float, float]]] = [] heads: list[tuple[tuple[float, float], str]] = [] tunnels: list[tuple[tuple[float, float], bool]] = [] if len(srcs) == 1 and len(tgts) >= 1: tgt_pts = [_end_point(t, arrow, geoms, work) for t in tgts] p1 = _end_point(srcs[0], arrow, geoms, work, mate=tgts[0] if len(tgts) == 1 else None) spine_x = None lane_y = None if srcs[0].box is None and srcs[0].side == "C" and len(tgts) > 1: xs = [p[0] for p in tgt_pts] spine_x = (min(xs) + max(xs)) / 2 lane_y = ch.c_lanes.get(id(arrow), work.y + 18 * s) p1 = (spine_x, work.y) for x in xs: if all(abs(x - u) >= ch.min_gap * 0.45 for u in ch.used_v): ch.used_v.append(x) elif srcs[0].box is None and srcs[0].side == "M" and len(tgts) > 1: xs = [p[0] for p in tgt_pts] spine_x = (min(xs) + max(xs)) / 2 lane_y = ch.m_lanes.get(id(arrow), work.b - 18 * s) p1 = (spine_x, work.b) for x in xs: if all(abs(x - u) >= ch.min_gap * 0.45 for u in ch.used_v): ch.used_v.append(x) mixed_oc = ( srcs[0].box and srcs[0].side == "O" and any(t.box is None and t.side == "O" for t in tgts) and any(t.box and t.side == "C" for t in tgts) ) _append_tunnel(tunnels, srcs[0], p1, s) for t, p2 in zip(tgts, tgt_pts): if mixed_oc and t.box and t.side == "C": drop_x = max(p2[0], p1[0] + ch.stub) pts = _simplify_ortho([p1, (drop_x, p1[1]), p2]) elif mixed_oc and t.box is None and t.side == "O": pts = _simplify_ortho([p1, (work.r, p1[1])]) else: pts = _route_icom( srcs[0], t, p1, p2, ch, geoms, order, arrow, lane_y=lane_y, spine_x=spine_x, ) paths.append(pts) heads.append((p2, _dir_for_into(t.side) if t.box else _dir_for_out(t.side))) _append_tunnel(tunnels, t, p2, s) elif len(tgts) == 1 and len(srcs) > 1: p2 = _end_point(tgts[0], arrow, geoms, work) for src in srcs: p1 = _end_point(src, arrow, geoms, work, mate=tgts[0]) pts = _route_icom(src, tgts[0], p1, p2, ch, geoms, order, arrow) paths.append(pts) _append_tunnel(tunnels, src, p1, s) heads.append((p2, _dir_for_into(tgts[0].side) if tgts[0].box else _dir_for_out(tgts[0].side))) _append_tunnel(tunnels, tgts[0], p2, s) else: return [] seen: set[tuple] = set() for pts in paths: for a, b in zip(pts, pts[1:]): key = _seg_key(a, b) if key in seen: continue seen.add(key) scene.line(a[0], a[1], b[0], b[1], lw) for tip, direction in heads: _arrowhead(scene, tip, direction, size=8 * s, lw=lw) for pt, horiz in tunnels: _draw_tunnel(scene, pt, horiz, s, lw) return paths def _draw_purpose(scene: _Scene, d: Idef0Diagram, work: _Rect, s: float, fonts: dict) -> _Rect | None: """Kept for callers; kit form does not put Purpose/Viewpoint in the work area.""" bits = [] if d.purpose: bits.append(f"Purpose: {d.purpose}") if d.viewpoint: bits.append(f"Viewpoint: {d.viewpoint}") if not bits: return None font = _load_font(int(fonts["small"]), False) lines: list[str] = [] for text in bits: lines.extend(_wrap(text, font, int(work.w * 0.40))) if not lines: return None pad = 3 * s tw, th = _label_block_size(lines, font, fonts["small"], pad) halo = _Rect(work.x + 4 * s, work.b - th - 4 * s, tw, th) scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) line_h = fonts["small"] * 1.15 y0 = halo.y + pad + fonts["small"] * 0.2 for i, ln in enumerate(lines): scene.text(halo.x + pad, y0 + i * line_h, ln, fonts["small"], anchor="lt") return halo.inflate(4 * s) def render_idef0( diagram: Idef0Diagram, *, scale: float = 2.0, want_svg: bool = True, ) -> tuple[bytes, str | None]: """Return (png_bytes, svg_text_or_None).""" s = max(float(scale), 1.0) n = max(1, len(diagram.boxes)) if n == 1: W, H = int(1200 * s), int(850 * s) elif n <= 3: W, H = int(1400 * s), int(920 * s) else: W, H = int(1860 * s), int(1280 * s) scene = _Scene(W, H) lw = max(1.0, 1.0 * s) fonts = { "lab": 9 * s, "val": 11 * s, "small": 9.5 * s, "box": 13 * s, "id": 10 * s, "arrow": 12 * s, "node": 20 * s, "title": 16 * s, "top": 22 * s, } outer = _Rect(8 * s, 8 * s, W - 16 * s, H - 16 * s) form = (diagram.form or "kit").strip().lower() if form == "plain": scene.rect(outer.x, outer.y, outer.w, outer.h, width=lw) work = _Rect(outer.x + 20 * s, outer.y + 20 * s, outer.w - 40 * s, outer.h - 40 * s) else: work = _draw_kit_form(scene, diagram, outer, s, fonts) geoms = _layout_boxes(diagram, work, s) _assign_slots(diagram, geoms) order = _box_order(diagram) ch = _make_channels(diagram, geoms, work, s) occupied: list[_Rect] = [g.rect.inflate(12 * s) for g in geoms.values()] boxes = [g.rect for g in geoms.values()] labeled: list[tuple[Idef0Arrow, list[list[tuple[float, float]]]]] = [] all_paths: list[list[tuple[float, float]]] = [] for arrow in diagram.arrows: paths = _draw_arrow(scene, arrow, geoms, work, s, lw, order, ch) labeled.append((arrow, paths)) all_paths.extend(paths) shafts = [ _seg_obstacle(a, b, 6 * s) for pts in all_paths for a, b in zip(pts, pts[1:]) ] # ICOM codes at the frame first so labels keep off them. for arrow, _paths in labeled: _draw_icom_code(scene, arrow, geoms, work, s, fonts, occupied) for arrow, paths in labeled: if arrow.label and paths: pts = _best_label_polyline(paths, min_len=24 * s) _draw_arrow_label( scene, pts, arrow.label, s, lw, fonts, work, occupied, boxes, shafts, ) for geom in geoms.values(): _draw_box(scene, geom, s, lw, fonts) png = scene.to_png() svg = scene.to_svg() if want_svg else None return png, svg def write_idef0_outputs( source: str, out_png: Path, out_svg: Path | None, *, scale: float = 2.0, ) -> bool: """Parse + render. Raises Idef0ParseError on bad DSL.""" diagram = parse_idef0(source) png, svg = render_idef0(diagram, scale=scale, want_svg=out_svg is not None) out_png.parent.mkdir(parents=True, exist_ok=True) out_png.write_bytes(png) if out_svg is not None and svg is not None: out_svg.write_text(svg, encoding="utf-8") _log.info("IDEF0 ok → %s", out_png.name) return True # Copy-paste sample for the classic A-0 context diagram. SAMPLE_CONTEXT = """\ title Распорядиться товаром node A-0 number 1 author Кинзябулатов Рамиль project Разработка универсальной модели торгового предприятия date 08.02.2022 rev 31.03.2022 status PUBLICATION context TOP [A0] Распорядиться товаром <- Спрос ^ Нормативная документация -> Товар v Персонал """ # A0 child of the context: 3-box Kinzyabulatov density (fork, O→C, ICOM at border). SAMPLE_DECOMPOSITION = """\ title Распорядиться товаром node A0 number 2 author Кинзябулатов Рамиль project Разработка универсальной модели торгового предприятия date 08.02.2022 rev 31.03.2022 status PUBLICATION context A-0 purpose Обеспечить наличие товара и его отпуск покупателю viewpoint Руководитель торгового предприятия [A1] Принять и оценить спрос [A2] Закупить и хранить [A3] Отгрузить товар A1 <- Спрос [I1] A2 <- Предложения поставщиков [I2] A1, A2, A3 ^ Нормативная документация [C1] A1 ^ Ассортиментная политика [C2] A3 ^ Условия поставки [C3] A1 -> A2 : план закупок A1 -> A2.C : график поставок A2 -> A3 : товар к отгрузке A3 -> Товар [O1] A3 -> Акт отгрузки [O2] A1, A2, A3 v Персонал [M1] A2 v Склад [M2] """