Files
md_to_gost/md2gost/idef0.py
T
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

2186 lines
72 KiB
Python

"""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'<svg xmlns="http://www.w3.org/2000/svg" width="{self.width}" height="{self.height}" '
f'viewBox="0 0 {self.width} {self.height}">',
'<rect width="100%" height="100%" fill="#fff"/>',
]
for op in self.ops:
kind = op[0]
if kind == "line":
_, x1, y1, x2, y2, w = op
parts.append(
f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
f'stroke="#000" stroke-width="{w:.2f}" stroke-linecap="square"/>'
)
elif kind == "polyline":
_, pts, w = op
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
parts.append(
f'<polyline points="{d}" fill="none" stroke="#000" '
f'stroke-width="{w:.2f}" stroke-linejoin="miter"/>'
)
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'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'fill="{fill_a}" stroke="{stroke}" stroke-width="{sw:.2f}"/>'
)
elif kind == "polygon":
_, pts, fill, outline, w = op
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
parts.append(
f'<polygon points="{d}" fill="{fill}" stroke="{outline}" stroke-width="{w:.2f}"/>'
)
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'<text x="{x:.1f}" y="{y:.1f}" dx="{dx}" dy="{dy}" text-anchor="{ta}" '
f'dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" '
f'font-size="{size:.1f}" font-weight="{weight}" fill="{fill}">'
f"{esc(text)}</text>"
)
elif kind == "arc":
_, bbox, start, end, w = op
parts.append(_svg_arc(bbox, start, end, w))
parts.append("</svg>")
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'<path d="M {sx:.1f},{sy:.1f} A {rx:.1f},{ry:.1f} 0 {large} 1 {ex:.1f},{ey:.1f}" '
f'fill="none" stroke="#000" stroke-width="{width:.2f}"/>'
)
# --- 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]
"""